spawnpoint-studio 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.
- spawnpoint/__init__.py +3 -0
- spawnpoint/_data/examples/coin_hopper.json +228 -0
- spawnpoint/_data/examples/neon_arena.json +251 -0
- spawnpoint/_data/examples/pixel_arena.json +242 -0
- spawnpoint/_data/examples/pixel_hopper.json +221 -0
- spawnpoint/_data/godot/addons/spawn_point/components/aim_component.gd +63 -0
- spawnpoint/_data/godot/addons/spawn_point/components/animator_component.gd +66 -0
- spawnpoint/_data/godot/addons/spawn_point/components/chase_ai_component.gd +107 -0
- spawnpoint/_data/godot/addons/spawn_point/components/collectible_component.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/components/gate_component.gd +64 -0
- spawnpoint/_data/godot/addons/spawn_point/components/health_component.gd +83 -0
- spawnpoint/_data/godot/addons/spawn_point/components/hitbox_component.gd +50 -0
- spawnpoint/_data/godot/addons/spawn_point/components/hurtbox_component.gd +48 -0
- spawnpoint/_data/godot/addons/spawn_point/components/juice_component.gd +219 -0
- spawnpoint/_data/godot/addons/spawn_point/components/patrol_ai_component.gd +63 -0
- spawnpoint/_data/godot/addons/spawn_point/components/platformer_movement_component.gd +204 -0
- spawnpoint/_data/godot/addons/spawn_point/components/player_input_component.gd +77 -0
- spawnpoint/_data/godot/addons/spawn_point/components/projectile_component.gd +64 -0
- spawnpoint/_data/godot/addons/spawn_point/components/spawner_component.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/components/stomp_component.gd +56 -0
- spawnpoint/_data/godot/addons/spawn_point/components/top_down_movement_component.gd +101 -0
- spawnpoint/_data/godot/addons/spawn_point/components/trigger_zone_component.gd +32 -0
- spawnpoint/_data/godot/addons/spawn_point/components/weapon_component.gd +83 -0
- spawnpoint/_data/godot/addons/spawn_point/manifest.json +1509 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/camera_rig.gd +71 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/cockpit.gd +62 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/entity_core.gd +98 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/fx.gd +112 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/game_state.gd +111 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/hud.gd +155 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/level.gd +19 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/sfx.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/sp.gd +121 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/world.gd +130 -0
- spawnpoint/_data/godot/addons/spawn_point/shaders/flash.gdshader +9 -0
- spawnpoint/_data/godot/addons/spawn_point/shaders/floor_grid.gdshader +13 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/build.gd +612 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/build.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/capture.gd +42 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/capture.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.gd +91 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest.gd +428 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest_nav.gd +152 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/record.gd +89 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/record.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/smoke.gd +72 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/smoke.tscn +6 -0
- spawnpoint/analyze.py +387 -0
- spawnpoint/build.py +146 -0
- spawnpoint/cli.py +290 -0
- spawnpoint/cockpit/__init__.py +0 -0
- spawnpoint/cockpit/server.py +473 -0
- spawnpoint/cockpit/static/app.css +111 -0
- spawnpoint/cockpit/static/app.js +327 -0
- spawnpoint/cockpit/static/index.html +79 -0
- spawnpoint/diagnostics.py +91 -0
- spawnpoint/expr.py +219 -0
- spawnpoint/gif.py +58 -0
- spawnpoint/godot.py +107 -0
- spawnpoint/graph.py +175 -0
- spawnpoint/level.py +157 -0
- spawnpoint/llm_server.py +77 -0
- spawnpoint/manifest.py +222 -0
- spawnpoint/playtest/__init__.py +104 -0
- spawnpoint/playtest/report.py +148 -0
- spawnpoint/playtest/runner.py +51 -0
- spawnpoint/playtest/tuner.py +151 -0
- spawnpoint/project.py +94 -0
- spawnpoint/py.typed +0 -0
- spawnpoint/rules.py +310 -0
- spawnpoint/sfx.py +352 -0
- spawnpoint/sprites/__init__.py +0 -0
- spawnpoint/sprites/ai.py +222 -0
- spawnpoint/sprites/contour.py +147 -0
- spawnpoint/sprites/generate.py +507 -0
- spawnpoint/sprites/importer.py +212 -0
- spawnpoint/sprites/resolve.py +139 -0
- spawnpoint/sprites/spriteset.py +159 -0
- spawnpoint/sprites/tiles.py +102 -0
- spawnpoint/studio/__init__.py +0 -0
- spawnpoint/studio/assemble.py +528 -0
- spawnpoint/studio/brief.py +197 -0
- spawnpoint/studio/creative.py +124 -0
- spawnpoint/studio/dataset.py +136 -0
- spawnpoint/studio/edits.py +206 -0
- spawnpoint/studio/levelgen.py +352 -0
- spawnpoint/studio/llm.py +145 -0
- spawnpoint/studio/orchestrator.py +341 -0
- spawnpoint/studio/prompts.py +122 -0
- spawnpoint/studio/web.py +123 -0
- spawnpoint/studio/webcheck.py +210 -0
- spawnpoint/studio_cli.py +232 -0
- spawnpoint_studio-0.1.0.dist-info/METADATA +360 -0
- spawnpoint_studio-0.1.0.dist-info/RECORD +118 -0
- spawnpoint_studio-0.1.0.dist-info/WHEEL +5 -0
- spawnpoint_studio-0.1.0.dist-info/entry_points.txt +2 -0
- spawnpoint_studio-0.1.0.dist-info/licenses/LICENSE +21 -0
- spawnpoint_studio-0.1.0.dist-info/top_level.txt +2 -0
- tests/conftest.py +49 -0
- tests/test_ai_sprites.py +122 -0
- tests/test_analyze.py +116 -0
- tests/test_cockpit.py +148 -0
- tests/test_e2e.py +33 -0
- tests/test_expr.py +59 -0
- tests/test_gif.py +19 -0
- tests/test_godot_library.py +17 -0
- tests/test_graph.py +44 -0
- tests/test_level.py +64 -0
- tests/test_live_llm.py +38 -0
- tests/test_manifest.py +54 -0
- tests/test_playtest.py +129 -0
- tests/test_rules.py +85 -0
- tests/test_sfx.py +30 -0
- tests/test_sprites.py +261 -0
- tests/test_studio.py +335 -0
- tests/test_web.py +41 -0
spawnpoint/rules.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Declarative rules -> generated GDScript (`rules.gd`).
|
|
2
|
+
|
|
3
|
+
A rule is {"on": <event>, "if": <expr>?, "do": [<action>...]}. Every reference is checked against
|
|
4
|
+
the graph and the component manifest, so the generated script is valid by construction.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from .diagnostics import Diagnostics, did_you_mean, join_path
|
|
13
|
+
from .expr import ExprError, Scope, _attr_chain, entity_literal, gd_string, normalize, translate
|
|
14
|
+
from .graph import Rule
|
|
15
|
+
from .manifest import Manifest
|
|
16
|
+
|
|
17
|
+
GAMESTATE_EVENTS: dict[str, list[str]] = {
|
|
18
|
+
"score_changed": ["score"],
|
|
19
|
+
"lives_changed": ["lives"],
|
|
20
|
+
"var_changed": ["name", "value"],
|
|
21
|
+
"second_passed": ["seconds"],
|
|
22
|
+
"game_won": [],
|
|
23
|
+
"game_lost": [],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# name -> (min args, max args, description)
|
|
27
|
+
BUILTINS: dict[str, tuple[int, int, str]] = {
|
|
28
|
+
"add_score": (1, 1, "add_score(n)"),
|
|
29
|
+
"set_var": (2, 2, 'set_var("name", value)'),
|
|
30
|
+
"add_var": (2, 2, 'add_var("name", delta)'),
|
|
31
|
+
"win": (0, 0, "win()"),
|
|
32
|
+
"lose": (0, 0, "lose()"),
|
|
33
|
+
"restart": (0, 0, "restart()"),
|
|
34
|
+
"spawn": (1, 1, 'spawn("Entity") at the emitting entity'),
|
|
35
|
+
"spawn_at": (3, 3, 'spawn_at("Entity", tile_x, tile_y)'),
|
|
36
|
+
"destroy": (1, 1, 'destroy(self) | destroy("Entity") | destroy(event.arg)'),
|
|
37
|
+
"shake": (1, 1, "shake(0..1)"),
|
|
38
|
+
"freeze": (1, 1, "freeze(seconds)"),
|
|
39
|
+
"sound": (1, 1, 'sound("name")'),
|
|
40
|
+
"message": (1, 1, 'message("text")'),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
HEADER = """\
|
|
44
|
+
# Generated by Spawn Point from the game graph (spawnpoint.json).
|
|
45
|
+
# Do not edit by hand: change the graph's "rules" and rebuild.
|
|
46
|
+
extends Node"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Event:
|
|
51
|
+
kind: str # "entity" | "gamestate" | "ready"
|
|
52
|
+
label: str
|
|
53
|
+
args: list[str]
|
|
54
|
+
entity: str | None = None
|
|
55
|
+
component: str | None = None
|
|
56
|
+
signal: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class RulesResult:
|
|
61
|
+
source: str
|
|
62
|
+
calls_win: bool
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def resolve_event(on: str, entities: dict[str, list[str]], manifest: Manifest) -> Event:
|
|
66
|
+
parts = on.strip().split(".")
|
|
67
|
+
if parts == ["Game", "ready"]:
|
|
68
|
+
return Event("ready", on, [])
|
|
69
|
+
if parts[0] == "GameState":
|
|
70
|
+
if len(parts) != 2 or parts[1] not in GAMESTATE_EVENTS:
|
|
71
|
+
raise ExprError(f"unknown GameState event '{on}'",
|
|
72
|
+
did_you_mean(parts[-1], GAMESTATE_EVENTS))
|
|
73
|
+
return Event("gamestate", on, GAMESTATE_EVENTS[parts[1]], signal=parts[1])
|
|
74
|
+
ent = parts[0]
|
|
75
|
+
if ent not in entities:
|
|
76
|
+
raise ExprError(f"unknown entity '{ent}' in event '{on}'",
|
|
77
|
+
did_you_mean(ent, [*entities, "GameState", "Game"]))
|
|
78
|
+
comps = entities[ent]
|
|
79
|
+
if len(parts) == 3:
|
|
80
|
+
comp_name, sig = parts[1], parts[2]
|
|
81
|
+
if comp_name not in comps:
|
|
82
|
+
raise ExprError(f"{ent} has no component '{comp_name}'", did_you_mean(comp_name, comps))
|
|
83
|
+
comp = manifest.get(comp_name)
|
|
84
|
+
if comp is None or sig not in comp.signals:
|
|
85
|
+
raise ExprError(f"{comp_name} has no signal '{sig}'", did_you_mean(sig, comp.signals if comp else []))
|
|
86
|
+
elif len(parts) == 2:
|
|
87
|
+
sig = parts[1]
|
|
88
|
+
owners = [c for c in comps if (m := manifest.get(c)) and sig in m.signals]
|
|
89
|
+
if not owners:
|
|
90
|
+
available = [f"{ent}.{s}" for c in comps if (m := manifest.get(c)) for s in m.signals]
|
|
91
|
+
raise ExprError(f"no component of {ent} emits '{sig}'",
|
|
92
|
+
did_you_mean(f"{ent}.{sig}", available) if available else f"{ent} has no signals")
|
|
93
|
+
if len(owners) > 1:
|
|
94
|
+
raise ExprError(f"'{on}' is ambiguous", "Use one of: " + ", ".join(f"{ent}.{o}.{sig}" for o in owners))
|
|
95
|
+
comp_name = owners[0]
|
|
96
|
+
else:
|
|
97
|
+
raise ExprError(f"cannot parse event '{on}'", "Use Entity.signal, Entity.Component.signal, GameState.<event> or Game.ready")
|
|
98
|
+
sig_def = manifest.get(comp_name).signals[sig]
|
|
99
|
+
return Event("entity", on, [a.name for a in sig_def.args], ent, comp_name, sig)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class _Actions:
|
|
103
|
+
"""Translates action calls of one rule into GDScript statements."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, scope: Scope, entities: dict[str, list[str]], manifest: Manifest):
|
|
106
|
+
self.scope = scope
|
|
107
|
+
self.entities = entities
|
|
108
|
+
self.manifest = manifest
|
|
109
|
+
self.calls_win = False
|
|
110
|
+
|
|
111
|
+
def expr(self, node: ast.AST) -> str:
|
|
112
|
+
return translate(ast.unparse(node), self.scope)
|
|
113
|
+
|
|
114
|
+
def translate(self, src: str) -> str:
|
|
115
|
+
try:
|
|
116
|
+
node = ast.parse(normalize(src).strip(), mode="eval").body
|
|
117
|
+
except SyntaxError as e:
|
|
118
|
+
raise ExprError(f"syntax error in action '{src}': {e.msg}") from e
|
|
119
|
+
if not isinstance(node, ast.Call):
|
|
120
|
+
raise ExprError(f"action '{src}' must be a call like win() or Door.open()")
|
|
121
|
+
if node.keywords:
|
|
122
|
+
raise ExprError("keyword arguments are not supported in actions")
|
|
123
|
+
if isinstance(node.func, ast.Name):
|
|
124
|
+
return self.builtin(node.func.id, node.args)
|
|
125
|
+
chain = _attr_chain(node.func)
|
|
126
|
+
if chain is None:
|
|
127
|
+
raise ExprError(f"cannot call '{ast.unparse(node.func)}'")
|
|
128
|
+
return self.method_call(chain, node.args, src)
|
|
129
|
+
|
|
130
|
+
def builtin(self, name: str, args: list[ast.expr]) -> str:
|
|
131
|
+
if name not in BUILTINS:
|
|
132
|
+
raise ExprError(f"unknown action '{name}()'", did_you_mean(name, BUILTINS))
|
|
133
|
+
lo, hi, usage = BUILTINS[name]
|
|
134
|
+
if not lo <= len(args) <= hi:
|
|
135
|
+
raise ExprError(f"wrong number of arguments for {name}()", f"Usage: {usage}")
|
|
136
|
+
e = self.expr
|
|
137
|
+
match name:
|
|
138
|
+
case "add_score":
|
|
139
|
+
return f"GameState.add_score(int({e(args[0])}))"
|
|
140
|
+
case "set_var" | "add_var":
|
|
141
|
+
key = _string_literal(args[0], f"{name}() needs the variable name in quotes")
|
|
142
|
+
return f"GameState.{name}({gd_string(key)}, {e(args[1])})"
|
|
143
|
+
case "win":
|
|
144
|
+
self.calls_win = True
|
|
145
|
+
return "GameState.win()"
|
|
146
|
+
case "lose":
|
|
147
|
+
return "GameState.lose()"
|
|
148
|
+
case "restart":
|
|
149
|
+
return "GameState.restart.call_deferred()"
|
|
150
|
+
case "spawn":
|
|
151
|
+
ent = entity_literal(args[0], self.entities)
|
|
152
|
+
if self.scope.self_entity is None:
|
|
153
|
+
raise ExprError("spawn() places the entity at the emitting entity, but this event has none",
|
|
154
|
+
f'Use spawn_at("{ent}", tile_x, tile_y)')
|
|
155
|
+
self.scope.used.add("source")
|
|
156
|
+
return f'World.spawn.call_deferred({gd_string(ent)}, source.global_position)'
|
|
157
|
+
case "spawn_at":
|
|
158
|
+
ent = entity_literal(args[0], self.entities)
|
|
159
|
+
return f"World.spawn_at_tile.call_deferred({gd_string(ent)}, int({e(args[1])}), int({e(args[2])}))"
|
|
160
|
+
case "destroy":
|
|
161
|
+
a = args[0]
|
|
162
|
+
if isinstance(a, ast.Name) and a.id == "self":
|
|
163
|
+
return f"World.destroy({e(a)})"
|
|
164
|
+
if isinstance(a, ast.Attribute):
|
|
165
|
+
return f"World.destroy({e(a)})"
|
|
166
|
+
ent = entity_literal(a, self.entities)
|
|
167
|
+
return f'World.destroy_all(&"{ent}")'
|
|
168
|
+
case "shake":
|
|
169
|
+
return f"FX.shake({e(args[0])})"
|
|
170
|
+
case "freeze":
|
|
171
|
+
return f"FX.hitstop({e(args[0])})"
|
|
172
|
+
case "sound":
|
|
173
|
+
snd = _string_literal(args[0], 'sound() needs a sound name in quotes, e.g. sound("coin")')
|
|
174
|
+
return f"Sfx.play({gd_string(snd)})"
|
|
175
|
+
case "message":
|
|
176
|
+
return f"GameState.message(str({e(args[0])}))"
|
|
177
|
+
raise AssertionError(name)
|
|
178
|
+
|
|
179
|
+
def method_call(self, chain: list[str], args: list[ast.expr], src: str) -> str:
|
|
180
|
+
head = chain[0]
|
|
181
|
+
if head == "event":
|
|
182
|
+
if len(chain) < 3:
|
|
183
|
+
raise ExprError(f"cannot call '{'.'.join(chain)}'", "Use event.<arg>.<Component>.<method>()")
|
|
184
|
+
target_code = self.expr(ast.parse(f"event.{chain[1]}", mode="eval").body)
|
|
185
|
+
rest = chain[2:]
|
|
186
|
+
ent_type = None
|
|
187
|
+
elif head == "self":
|
|
188
|
+
if self.scope.self_entity is None:
|
|
189
|
+
raise ExprError("'self' is not available for this event (no emitting entity)")
|
|
190
|
+
self.scope.used.add("source")
|
|
191
|
+
target_code = "source"
|
|
192
|
+
rest = chain[1:]
|
|
193
|
+
ent_type = self.scope.self_entity
|
|
194
|
+
elif head in self.entities:
|
|
195
|
+
target_code = None
|
|
196
|
+
rest = chain[1:]
|
|
197
|
+
ent_type = head
|
|
198
|
+
else:
|
|
199
|
+
raise ExprError(f"unknown call target '{head}' in '{src}'",
|
|
200
|
+
did_you_mean(head, [*self.entities, "self", "event"]))
|
|
201
|
+
|
|
202
|
+
if len(rest) == 2:
|
|
203
|
+
comp_name, method = rest
|
|
204
|
+
elif len(rest) == 1:
|
|
205
|
+
method = rest[0]
|
|
206
|
+
comp_name = self._component_for(method, ent_type)
|
|
207
|
+
else:
|
|
208
|
+
raise ExprError(f"cannot call '{'.'.join(chain)}'", "Use Entity.method() or Entity.Component.method()")
|
|
209
|
+
|
|
210
|
+
comp = self.manifest.get(comp_name)
|
|
211
|
+
if comp is None:
|
|
212
|
+
raise ExprError(f"unknown component '{comp_name}'", did_you_mean(comp_name, self.manifest.components))
|
|
213
|
+
if ent_type is not None and comp_name not in self.entities[ent_type]:
|
|
214
|
+
raise ExprError(f"{ent_type} has no component '{comp_name}'", did_you_mean(comp_name, self.entities[ent_type]))
|
|
215
|
+
m = comp.methods.get(method)
|
|
216
|
+
if m is None:
|
|
217
|
+
raise ExprError(f"{comp_name} has no method '{method}'", did_you_mean(method, comp.methods))
|
|
218
|
+
if not m.min_args <= len(args) <= len(m.args):
|
|
219
|
+
sig = ", ".join(a.name for a in m.args)
|
|
220
|
+
raise ExprError(f"{comp_name}.{method}() takes {m.min_args}-{len(m.args)} argument(s), got {len(args)}",
|
|
221
|
+
f"Signature: {method}({sig})")
|
|
222
|
+
arg_code = "[" + ", ".join(self.expr(a) for a in args) + "]"
|
|
223
|
+
if target_code is None:
|
|
224
|
+
return f'World.call_all(&"{ent_type}", &"{comp_name}", &"{method}", {arg_code})'
|
|
225
|
+
return f'World.call_on({target_code}, &"{comp_name}", &"{method}", {arg_code})'
|
|
226
|
+
|
|
227
|
+
def _component_for(self, method: str, ent_type: str | None) -> str:
|
|
228
|
+
candidates = self.entities[ent_type] if ent_type else list(self.manifest.components)
|
|
229
|
+
owners = [c for c in candidates if (m := self.manifest.get(c)) and method in m.methods]
|
|
230
|
+
if not owners:
|
|
231
|
+
available = sorted({mm for c in candidates if (m := self.manifest.get(c)) for mm in m.methods})
|
|
232
|
+
where = ent_type or "any component"
|
|
233
|
+
raise ExprError(f"{where} has no method '{method}'", did_you_mean(method, available))
|
|
234
|
+
if len(owners) > 1:
|
|
235
|
+
raise ExprError(f"method '{method}' is ambiguous", "Name the component: " + ", ".join(f"{o}.{method}()" for o in owners))
|
|
236
|
+
return owners[0]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _string_literal(node: ast.AST, message: str) -> str:
|
|
240
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
241
|
+
return node.value
|
|
242
|
+
raise ExprError(message)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def compile_rules(rules: list[Rule], entities: dict[str, list[str]], manifest: Manifest,
|
|
246
|
+
diags: Diagnostics) -> RulesResult:
|
|
247
|
+
binds: list[str] = []
|
|
248
|
+
ready_calls: list[str] = []
|
|
249
|
+
funcs: list[str] = []
|
|
250
|
+
calls_win = False
|
|
251
|
+
for i, rule in enumerate(rules):
|
|
252
|
+
path = join_path("rules", i)
|
|
253
|
+
try:
|
|
254
|
+
ev = resolve_event(rule.on, entities, manifest)
|
|
255
|
+
except ExprError as e:
|
|
256
|
+
diags.error("E_RULE_EVENT", path + ".on", str(e), e.hint)
|
|
257
|
+
continue
|
|
258
|
+
arg_names = {a: f"a_{a}" for a in ev.args}
|
|
259
|
+
scope = Scope(manifest, entities, arg_names, ev.entity if ev.kind == "entity" else None)
|
|
260
|
+
ok = True
|
|
261
|
+
cond = None
|
|
262
|
+
if rule.if_:
|
|
263
|
+
try:
|
|
264
|
+
cond = translate(rule.if_, scope)
|
|
265
|
+
except ExprError as e:
|
|
266
|
+
diags.error("E_RULE_IF", path + ".if", str(e), e.hint)
|
|
267
|
+
ok = False
|
|
268
|
+
actions = _Actions(scope, entities, manifest)
|
|
269
|
+
body: list[str] = []
|
|
270
|
+
if not rule.actions:
|
|
271
|
+
diags.error("E_RULE_DO", path + ".do", "rule has no actions")
|
|
272
|
+
ok = False
|
|
273
|
+
for j, act in enumerate(rule.actions):
|
|
274
|
+
try:
|
|
275
|
+
body.append(actions.translate(act))
|
|
276
|
+
except ExprError as e:
|
|
277
|
+
diags.error("E_RULE_DO", join_path(path, "do", j), str(e), e.hint)
|
|
278
|
+
ok = False
|
|
279
|
+
if not ok:
|
|
280
|
+
continue
|
|
281
|
+
calls_win |= actions.calls_win
|
|
282
|
+
|
|
283
|
+
fname = f"_rule_{i}"
|
|
284
|
+
params = [(gd if gd in scope.used else "_" + gd) for gd in arg_names.values()]
|
|
285
|
+
if ev.kind == "entity":
|
|
286
|
+
params.append("source" if "source" in scope.used else "_source")
|
|
287
|
+
binds.append(f'World.bind_signal(&"{ev.entity}", &"{ev.component}", &"{ev.signal}", {fname})')
|
|
288
|
+
elif ev.kind == "gamestate":
|
|
289
|
+
binds.append(f"GameState.{ev.signal}.connect({fname})")
|
|
290
|
+
else:
|
|
291
|
+
ready_calls.append(f"{fname}()")
|
|
292
|
+
|
|
293
|
+
comment = f"# Rule {i}: on {rule.on}" + (f" if {rule.if_}" if rule.if_ else "")
|
|
294
|
+
lines = [comment, f"func {fname}({', '.join(params)}) -> void:"]
|
|
295
|
+
if cond:
|
|
296
|
+
lines += [f"\tif not {cond}:", "\t\treturn"]
|
|
297
|
+
lines += [f"\t{stmt}" for stmt in body]
|
|
298
|
+
funcs.append("\n".join(lines))
|
|
299
|
+
|
|
300
|
+
ready = ["func _ready() -> void:"]
|
|
301
|
+
ready += [f"\t{b}" for b in binds]
|
|
302
|
+
if ready_calls:
|
|
303
|
+
ready.append("\t_on_game_ready.call_deferred()")
|
|
304
|
+
if len(ready) == 1:
|
|
305
|
+
ready.append("\tpass")
|
|
306
|
+
chunks = [HEADER, "\n".join(ready)]
|
|
307
|
+
if ready_calls:
|
|
308
|
+
chunks.append("\n".join(["func _on_game_ready() -> void:", *[f"\t{c}" for c in ready_calls]]))
|
|
309
|
+
chunks += funcs
|
|
310
|
+
return RulesResult("\n\n\n".join(chunks) + "\n", calls_win)
|
spawnpoint/sfx.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Procedural retro sound effects (a port of DrPetter's sfxr synth and its preset generators).
|
|
2
|
+
|
|
3
|
+
Every game gets jump / land / hurt / shoot / coin / explosion / powerup / dash / blip sounds,
|
|
4
|
+
deterministic for a given seed, so the first build already has audio feedback.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import io
|
|
10
|
+
import math
|
|
11
|
+
import random
|
|
12
|
+
import struct
|
|
13
|
+
import wave
|
|
14
|
+
from dataclasses import dataclass, replace
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
SAMPLE_RATE = 44100
|
|
18
|
+
SQUARE, SAW, SINE, NOISE = 0, 1, 2, 3
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class SfxParams:
|
|
23
|
+
wave_type: int = SQUARE
|
|
24
|
+
base_freq: float = 0.3
|
|
25
|
+
freq_limit: float = 0.0
|
|
26
|
+
freq_ramp: float = 0.0
|
|
27
|
+
freq_dramp: float = 0.0
|
|
28
|
+
duty: float = 0.0
|
|
29
|
+
duty_ramp: float = 0.0
|
|
30
|
+
vib_strength: float = 0.0
|
|
31
|
+
vib_speed: float = 0.0
|
|
32
|
+
env_attack: float = 0.0
|
|
33
|
+
env_sustain: float = 0.3
|
|
34
|
+
env_decay: float = 0.4
|
|
35
|
+
env_punch: float = 0.0
|
|
36
|
+
lpf_resonance: float = 0.0
|
|
37
|
+
lpf_freq: float = 1.0
|
|
38
|
+
lpf_ramp: float = 0.0
|
|
39
|
+
hpf_freq: float = 0.0
|
|
40
|
+
hpf_ramp: float = 0.0
|
|
41
|
+
pha_offset: float = 0.0
|
|
42
|
+
pha_ramp: float = 0.0
|
|
43
|
+
repeat_speed: float = 0.0
|
|
44
|
+
arp_speed: float = 0.0
|
|
45
|
+
arp_mod: float = 0.0
|
|
46
|
+
volume: float = 0.6
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --- preset generators (after sfxr's "generator" buttons) -------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _pickup(r: random.Random) -> SfxParams:
|
|
53
|
+
p = SfxParams(base_freq=0.4 + r.random() * 0.5, env_sustain=r.random() * 0.1,
|
|
54
|
+
env_decay=0.1 + r.random() * 0.4, env_punch=0.3 + r.random() * 0.3)
|
|
55
|
+
p.arp_speed = 0.5 + r.random() * 0.2
|
|
56
|
+
p.arp_mod = 0.2 + r.random() * 0.4
|
|
57
|
+
return p
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _laser(r: random.Random) -> SfxParams:
|
|
61
|
+
p = SfxParams(wave_type=r.choice([SQUARE, SAW, SINE]))
|
|
62
|
+
p.base_freq = 0.5 + r.random() * 0.5
|
|
63
|
+
p.freq_limit = max(0.2, p.base_freq - 0.2 - r.random() * 0.6)
|
|
64
|
+
p.freq_ramp = -0.15 - r.random() * 0.2
|
|
65
|
+
if r.random() < 0.5:
|
|
66
|
+
p.duty, p.duty_ramp = r.random() * 0.5, r.random() * 0.2
|
|
67
|
+
else:
|
|
68
|
+
p.duty, p.duty_ramp = 0.4 + r.random() * 0.5, -r.random() * 0.7
|
|
69
|
+
p.env_sustain = 0.1 + r.random() * 0.2
|
|
70
|
+
p.env_decay = r.random() * 0.4
|
|
71
|
+
if r.random() < 0.5:
|
|
72
|
+
p.env_punch = r.random() * 0.3
|
|
73
|
+
if r.random() < 0.33:
|
|
74
|
+
p.pha_offset, p.pha_ramp = r.random() * 0.2, -r.random() * 0.2
|
|
75
|
+
p.hpf_freq = r.random() * 0.3 if r.random() < 0.5 else 0.0
|
|
76
|
+
p.volume = 0.45
|
|
77
|
+
return p
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _explosion(r: random.Random) -> SfxParams:
|
|
81
|
+
p = SfxParams(wave_type=NOISE)
|
|
82
|
+
if r.random() < 0.5:
|
|
83
|
+
p.base_freq, p.freq_ramp = 0.1 + r.random() * 0.4, -0.1 + r.random() * 0.4
|
|
84
|
+
else:
|
|
85
|
+
p.base_freq, p.freq_ramp = 0.2 + r.random() * 0.7, -0.2 - r.random() * 0.2
|
|
86
|
+
p.base_freq *= p.base_freq
|
|
87
|
+
if r.random() < 0.2:
|
|
88
|
+
p.freq_ramp = 0.0
|
|
89
|
+
if r.random() < 0.33:
|
|
90
|
+
p.repeat_speed = 0.3 + r.random() * 0.5
|
|
91
|
+
p.env_sustain = 0.1 + r.random() * 0.3
|
|
92
|
+
p.env_decay = 0.2 + r.random() * 0.4
|
|
93
|
+
if r.random() < 0.5:
|
|
94
|
+
p.pha_offset, p.pha_ramp = -0.3 + r.random() * 0.9, -r.random() * 0.3
|
|
95
|
+
p.env_punch = 0.2 + r.random() * 0.6
|
|
96
|
+
if r.random() < 0.5:
|
|
97
|
+
p.vib_strength, p.vib_speed = r.random() * 0.7, r.random() * 0.6
|
|
98
|
+
return p
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _powerup(r: random.Random) -> SfxParams:
|
|
102
|
+
p = SfxParams()
|
|
103
|
+
if r.random() < 0.5:
|
|
104
|
+
p.wave_type = SAW
|
|
105
|
+
else:
|
|
106
|
+
p.duty = r.random() * 0.6
|
|
107
|
+
if r.random() < 0.5:
|
|
108
|
+
p.base_freq, p.freq_ramp, p.repeat_speed = 0.2 + r.random() * 0.3, 0.1 + r.random() * 0.4, 0.4 + r.random() * 0.4
|
|
109
|
+
else:
|
|
110
|
+
p.base_freq, p.freq_ramp = 0.2 + r.random() * 0.3, 0.05 + r.random() * 0.2
|
|
111
|
+
if r.random() < 0.5:
|
|
112
|
+
p.vib_strength, p.vib_speed = r.random() * 0.7, r.random() * 0.6
|
|
113
|
+
p.env_sustain = r.random() * 0.4
|
|
114
|
+
p.env_decay = 0.1 + r.random() * 0.4
|
|
115
|
+
return p
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _hurt(r: random.Random) -> SfxParams:
|
|
119
|
+
p = SfxParams(wave_type=r.choice([SQUARE, SAW, NOISE]))
|
|
120
|
+
if p.wave_type == SQUARE:
|
|
121
|
+
p.duty = r.random() * 0.6
|
|
122
|
+
p.base_freq = 0.2 + r.random() * 0.6
|
|
123
|
+
p.freq_ramp = -0.3 - r.random() * 0.4
|
|
124
|
+
p.env_sustain = r.random() * 0.1
|
|
125
|
+
p.env_decay = 0.1 + r.random() * 0.2
|
|
126
|
+
if r.random() < 0.5:
|
|
127
|
+
p.hpf_freq = r.random() * 0.3
|
|
128
|
+
return p
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _jump(r: random.Random) -> SfxParams:
|
|
132
|
+
p = SfxParams(wave_type=SQUARE, duty=r.random() * 0.6)
|
|
133
|
+
p.base_freq = 0.3 + r.random() * 0.3
|
|
134
|
+
p.freq_ramp = 0.1 + r.random() * 0.2
|
|
135
|
+
p.env_sustain = 0.1 + r.random() * 0.3
|
|
136
|
+
p.env_decay = 0.1 + r.random() * 0.2
|
|
137
|
+
if r.random() < 0.5:
|
|
138
|
+
p.hpf_freq = r.random() * 0.3
|
|
139
|
+
if r.random() < 0.5:
|
|
140
|
+
p.lpf_freq = 1.0 - r.random() * 0.6
|
|
141
|
+
p.volume = 0.45
|
|
142
|
+
return p
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _blip(r: random.Random) -> SfxParams:
|
|
146
|
+
p = SfxParams(wave_type=r.choice([SQUARE, SAW]))
|
|
147
|
+
if p.wave_type == SQUARE:
|
|
148
|
+
p.duty = r.random() * 0.6
|
|
149
|
+
p.base_freq = 0.2 + r.random() * 0.4
|
|
150
|
+
p.env_sustain = 0.1 + r.random() * 0.1
|
|
151
|
+
p.env_decay = r.random() * 0.2
|
|
152
|
+
p.hpf_freq = 0.1
|
|
153
|
+
return p
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _land(r: random.Random) -> SfxParams:
|
|
157
|
+
# A soft, low thud.
|
|
158
|
+
return SfxParams(wave_type=NOISE, base_freq=0.08 + r.random() * 0.04, freq_ramp=-0.1,
|
|
159
|
+
env_sustain=0.02, env_decay=0.12 + r.random() * 0.05, env_punch=0.4,
|
|
160
|
+
lpf_freq=0.35, volume=0.5)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _dash(r: random.Random) -> SfxParams:
|
|
164
|
+
# A breathy whoosh.
|
|
165
|
+
return SfxParams(wave_type=NOISE, base_freq=0.35 + r.random() * 0.1, freq_ramp=-0.25,
|
|
166
|
+
env_attack=0.05, env_sustain=0.08, env_decay=0.18, lpf_freq=0.5, lpf_ramp=-0.2,
|
|
167
|
+
hpf_freq=0.15, volume=0.45)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
PRESETS = {
|
|
171
|
+
"jump": _jump,
|
|
172
|
+
"land": _land,
|
|
173
|
+
"hurt": _hurt,
|
|
174
|
+
"shoot": _laser,
|
|
175
|
+
"coin": _pickup,
|
|
176
|
+
"explosion": _explosion,
|
|
177
|
+
"powerup": _powerup,
|
|
178
|
+
"dash": _dash,
|
|
179
|
+
"blip": _blip,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def preset(name: str, seed: int = 0) -> SfxParams:
|
|
184
|
+
# Stable per-preset seed so changing one preset never changes the others.
|
|
185
|
+
r = random.Random(f"{name}:{seed}")
|
|
186
|
+
return PRESETS[name](r)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# --- synthesis --------------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def synthesize(p: SfxParams, seed: int = 0, max_seconds: float = 2.0) -> list[float]:
|
|
193
|
+
rnd = random.Random(seed)
|
|
194
|
+
out: list[float] = []
|
|
195
|
+
|
|
196
|
+
def reset(restart: bool) -> None:
|
|
197
|
+
nonlocal fperiod, fmaxperiod, fslide, fdslide, period, square_duty, square_slide
|
|
198
|
+
nonlocal arp_mod, arp_time, arp_limit
|
|
199
|
+
fperiod = 100.0 / (p.base_freq * p.base_freq + 0.001)
|
|
200
|
+
period = int(fperiod)
|
|
201
|
+
fmaxperiod = 100.0 / (p.freq_limit * p.freq_limit + 0.001)
|
|
202
|
+
fslide = 1.0 - p.freq_ramp**3 * 0.01
|
|
203
|
+
fdslide = -(p.freq_dramp**3) * 0.000001
|
|
204
|
+
square_duty = 0.5 - p.duty * 0.5
|
|
205
|
+
square_slide = -p.duty_ramp * 0.00005
|
|
206
|
+
arp_mod = 1.0 - p.arp_mod**2 * 0.9 if p.arp_mod >= 0 else 1.0 + p.arp_mod**2 * 10.0
|
|
207
|
+
arp_time = 0
|
|
208
|
+
arp_limit = int((1.0 - p.arp_speed) ** 2 * 20000 + 32)
|
|
209
|
+
if p.arp_speed == 1.0:
|
|
210
|
+
arp_limit = 0
|
|
211
|
+
|
|
212
|
+
fperiod = fmaxperiod = fslide = fdslide = square_duty = square_slide = arp_mod = 0.0
|
|
213
|
+
period = arp_time = arp_limit = 0
|
|
214
|
+
reset(False)
|
|
215
|
+
|
|
216
|
+
phase = 0
|
|
217
|
+
fltp = fltdp = fltphp = 0.0
|
|
218
|
+
fltw = p.lpf_freq**3 * 0.1
|
|
219
|
+
fltw_d = 1.0 + p.lpf_ramp * 0.0001
|
|
220
|
+
fltdmp = min(5.0 / (1.0 + p.lpf_resonance**2 * 20.0) * (0.01 + fltw), 0.8)
|
|
221
|
+
flthp = p.hpf_freq**2 * 0.1
|
|
222
|
+
flthp_d = 1.0 + p.hpf_ramp * 0.0003
|
|
223
|
+
vib_phase = 0.0
|
|
224
|
+
vib_speed = p.vib_speed**2 * 0.01
|
|
225
|
+
vib_amp = p.vib_strength * 0.5
|
|
226
|
+
env_vol = 0.0
|
|
227
|
+
env_stage = 0
|
|
228
|
+
env_time = 0
|
|
229
|
+
env_length = [max(1, int(p.env_attack**2 * 100000)), max(1, int(p.env_sustain**2 * 100000)),
|
|
230
|
+
max(1, int(p.env_decay**2 * 100000))]
|
|
231
|
+
fphase = p.pha_offset**2 * 1020.0 * (1 if p.pha_offset >= 0 else -1)
|
|
232
|
+
fdphase = p.pha_ramp**2 * (1 if p.pha_ramp >= 0 else -1)
|
|
233
|
+
iphase = abs(int(fphase))
|
|
234
|
+
ipp = 0
|
|
235
|
+
phaser = [0.0] * 1024
|
|
236
|
+
noise = [rnd.uniform(-1, 1) for _ in range(32)]
|
|
237
|
+
rep_time = 0
|
|
238
|
+
rep_limit = int((1.0 - p.repeat_speed) ** 2 * 20000 + 32)
|
|
239
|
+
if p.repeat_speed == 0.0:
|
|
240
|
+
rep_limit = 0
|
|
241
|
+
|
|
242
|
+
for _ in range(int(SAMPLE_RATE * max_seconds)):
|
|
243
|
+
rep_time += 1
|
|
244
|
+
if rep_limit and rep_time >= rep_limit:
|
|
245
|
+
rep_time = 0
|
|
246
|
+
reset(True)
|
|
247
|
+
arp_time += 1
|
|
248
|
+
if arp_limit and arp_time >= arp_limit:
|
|
249
|
+
arp_limit = 0
|
|
250
|
+
fperiod *= arp_mod
|
|
251
|
+
fslide += fdslide
|
|
252
|
+
fperiod *= fslide
|
|
253
|
+
if fperiod > fmaxperiod:
|
|
254
|
+
fperiod = fmaxperiod
|
|
255
|
+
if p.freq_limit > 0.0:
|
|
256
|
+
break
|
|
257
|
+
rfperiod = fperiod
|
|
258
|
+
if vib_amp > 0.0:
|
|
259
|
+
vib_phase += vib_speed
|
|
260
|
+
rfperiod = fperiod * (1.0 + math.sin(vib_phase) * vib_amp)
|
|
261
|
+
period = max(int(rfperiod), 8)
|
|
262
|
+
square_duty = min(max(square_duty + square_slide, 0.0), 0.5)
|
|
263
|
+
|
|
264
|
+
env_time += 1
|
|
265
|
+
if env_time > env_length[env_stage]:
|
|
266
|
+
env_time = 0
|
|
267
|
+
env_stage += 1
|
|
268
|
+
if env_stage == 3:
|
|
269
|
+
break
|
|
270
|
+
if env_stage == 0:
|
|
271
|
+
env_vol = env_time / env_length[0]
|
|
272
|
+
elif env_stage == 1:
|
|
273
|
+
env_vol = 1.0 + (1.0 - env_time / env_length[1]) * 2.0 * p.env_punch
|
|
274
|
+
else:
|
|
275
|
+
env_vol = 1.0 - env_time / env_length[2]
|
|
276
|
+
|
|
277
|
+
fphase += fdphase
|
|
278
|
+
iphase = min(abs(int(fphase)), 1023)
|
|
279
|
+
if flthp_d != 1.0:
|
|
280
|
+
flthp = min(max(flthp * flthp_d, 0.00001), 0.1)
|
|
281
|
+
|
|
282
|
+
ssample = 0.0
|
|
283
|
+
for _s in range(8): # 8x supersampling
|
|
284
|
+
phase += 1
|
|
285
|
+
if phase >= period:
|
|
286
|
+
phase %= period
|
|
287
|
+
if p.wave_type == NOISE:
|
|
288
|
+
noise = [rnd.uniform(-1, 1) for _ in range(32)]
|
|
289
|
+
fp = phase / period
|
|
290
|
+
if p.wave_type == SQUARE:
|
|
291
|
+
sample = 0.5 if fp < square_duty else -0.5
|
|
292
|
+
elif p.wave_type == SAW:
|
|
293
|
+
sample = 1.0 - fp * 2.0
|
|
294
|
+
elif p.wave_type == SINE:
|
|
295
|
+
sample = math.sin(fp * 2.0 * math.pi)
|
|
296
|
+
else:
|
|
297
|
+
sample = noise[phase * 32 // period]
|
|
298
|
+
pp = fltp
|
|
299
|
+
fltw = min(max(fltw * fltw_d, 0.0), 0.1)
|
|
300
|
+
if p.lpf_freq != 1.0:
|
|
301
|
+
fltdp += (sample - fltp) * fltw
|
|
302
|
+
fltdp -= fltdp * fltdmp
|
|
303
|
+
else:
|
|
304
|
+
fltp = sample
|
|
305
|
+
fltdp = 0.0
|
|
306
|
+
fltp += fltdp
|
|
307
|
+
fltphp += fltp - pp
|
|
308
|
+
fltphp -= fltphp * flthp
|
|
309
|
+
sample = fltphp
|
|
310
|
+
phaser[ipp & 1023] = sample
|
|
311
|
+
sample += phaser[(ipp - iphase + 1024) & 1023]
|
|
312
|
+
ipp = (ipp + 1) & 1023
|
|
313
|
+
ssample += sample * env_vol
|
|
314
|
+
out.append(ssample / 8.0)
|
|
315
|
+
|
|
316
|
+
# Normalize to the requested loudness and fade the last few ms to avoid clicks.
|
|
317
|
+
peak = max((abs(s) for s in out), default=0.0)
|
|
318
|
+
if peak > 0:
|
|
319
|
+
k = p.volume / peak
|
|
320
|
+
out = [s * k for s in out]
|
|
321
|
+
fade = min(len(out), int(SAMPLE_RATE * 0.005))
|
|
322
|
+
for i in range(fade):
|
|
323
|
+
out[-1 - i] *= i / fade
|
|
324
|
+
return out
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def to_wav_bytes(samples: list[float]) -> bytes:
|
|
328
|
+
buf = io.BytesIO()
|
|
329
|
+
with wave.open(buf, "wb") as w:
|
|
330
|
+
w.setnchannels(1)
|
|
331
|
+
w.setsampwidth(2)
|
|
332
|
+
w.setframerate(SAMPLE_RATE)
|
|
333
|
+
w.writeframes(b"".join(struct.pack("<h", int(max(-1.0, min(1.0, s)) * 32767)) for s in samples))
|
|
334
|
+
return buf.getvalue()
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def write_all(out_dir: Path, seed: int = 0, names: list[str] | None = None) -> list[Path]:
|
|
338
|
+
"""Writes <name>.wav for each preset. Skips files whose params did not change."""
|
|
339
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
340
|
+
written = []
|
|
341
|
+
for name in names or list(PRESETS):
|
|
342
|
+
params = preset(name, seed)
|
|
343
|
+
stamp = out_dir / f".{name}.params"
|
|
344
|
+
target = out_dir / f"{name}.wav"
|
|
345
|
+
key = repr(replace(params))
|
|
346
|
+
if target.exists() and stamp.exists() and stamp.read_text() == key:
|
|
347
|
+
written.append(target)
|
|
348
|
+
continue
|
|
349
|
+
target.write_bytes(to_wav_bytes(synthesize(params, seed)))
|
|
350
|
+
stamp.write_text(key)
|
|
351
|
+
written.append(target)
|
|
352
|
+
return written
|
|
File without changes
|