Skip to content

Commit a063ae0

Browse files
authored
Merge pull request #10 from sonicfieldlabs/agent/germ-v0.4.1
Release GERM 0.4.1
2 parents 996812f + 6ecbeda commit a063ae0

16 files changed

Lines changed: 1418 additions & 138 deletions

File tree

.github/workflows/ci.yml

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,24 @@ on:
77
permissions:
88
contents: read
99

10+
concurrency:
11+
group: ci-${{ github.workflow }}-${{ github.ref }}
12+
cancel-in-progress: true
13+
1014
jobs:
1115
test:
12-
runs-on: ubuntu-latest
16+
runs-on: ubuntu-24.04
17+
timeout-minutes: 30
1318
steps:
1419
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
20+
with:
21+
persist-credentials: false
1522
- uses: astral-sh/setup-uv@e58605a9b6da7c637471fab8847a5e5a6b8df081 # v5
1623
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
1724
with:
18-
node-version: "22"
25+
node-version: "22.22.3"
1926
- name: Install Python dependencies
20-
run: uv sync --extra dev
27+
run: uv sync --extra dev --locked
2128
- name: Python tests
2229
run: uv run pytest -q
2330
- name: Ruff
@@ -38,7 +45,10 @@ jobs:
3845
- name: Dashboard syntax
3946
run: |
4047
node --check dashboard/static/app.js
48+
node --check dashboard/static/audio_engine.js
49+
node --check dashboard/static/cosmoaudition.js
4150
node --check dashboard/static/dish.js
51+
node --check dashboard/static/docs_theme.js
4252
node --check dashboard/static/micro_forms.js
4353
node --check dashboard/static/micro_render.js
4454
node --check dashboard/static/micro_unicode.js

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,30 @@
11
# Changelog
22

3+
## 0.4.1 — Sonic Matter Framework contract corrections
4+
5+
- Fixed the Cosmoaudition frame bridge to consume the published
6+
`outputValue` field, expose an explicit GERM `value`, and retain the control's
7+
status, mapping parameters, confidence, and epistemic note. Real applied,
8+
held, and uncertainty routes are no longer silently withheld.
9+
- Kept canonical `sound_id`, Sonic Lineage, and Earworm identifiers stable
10+
when filenames change; refreshed written MASA sidecars after path changes and
11+
refused renames that would break an immutable Akousmata file locator. Rename
12+
companion writes are transactional, and matching deferred-latent locators
13+
follow their audio without changing identity.
14+
- Restricted bulk file deletion's primary path to supported audio formats,
15+
required the actual metadata companion, preserved Akousmata locators, and
16+
removed only verified written MASA sidecars under explicit companion-delete
17+
semantics.
18+
- Preserved Cosmoaudition frame-level signals and sources beside resolved
19+
routes, and withheld malformed or out-of-range executable controls with an
20+
explicit bridge reason.
21+
- Accepted finite JavaScript wire numbers whose integer spelling exceeds a
22+
signed 64-bit persistence value, so high-magnitude observations such as the
23+
Bitcoin hashrate cannot invalidate an otherwise valid modulation frame.
24+
- Aligned active project language to **Sonic Matter Framework**: MASA
25+
describes, Cosmoaudition observes and modulates, and GERM cultivates. The
26+
projects remain distinct.
27+
328
## 0.4.0 — Sonic Matter Stack alignment
429

530
- **Fixed a MASA protocol break.** Every sidecar cited

CITATION.cff

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ authors:
55
- family-names: "Isaza"
66
given-names: "eme"
77
affiliation: "Sonic Field Labs"
8-
version: "0.4.0"
9-
date-released: "2026-08-07"
8+
version: "0.4.1"
9+
date-released: "2026-08-09"
1010
license: "MPL-2.0"
1111
repository-code: "https://github.com/sonicfieldlabs/germ"
1212
abstract: "germ is a local generative microsound environment whose generated sounds retain prompts, parents, mutations, listening metadata, and Earworm-compatible lineage."

README.md

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ listened to, and traced through lineage. A listening from Oída can become a
88
prompt or source in GERM; a successful render can become a descendant in
99
Akousmata and return to Oída for another listening.
1010

11-
Current release: `0.4.0`.
11+
Current release: `0.4.1`.
1212

1313
GERM is part of two Sonic Field Labs constellations: it cultivates within
1414
[The Listening Stack](https://sonicfield.org/stack) and describes its material
15-
through the [Sonic Matter Stack](#sonic-matter-stack-integration), whose
15+
through the [Sonic Matter Framework](#sonic-matter-framework-integration), whose
1616
protocol boundary is MASA 0.1.0.
1717

1818
GERM is an independent Sonic Field Labs project. It can use Stable Audio 3
@@ -127,20 +127,21 @@ promising one universal hardware minimum.
127127
All three scales use the same module graph, semantic FX bridge, sessions,
128128
library, and lineage model.
129129

130-
## Sonic Matter Stack integration
130+
## Sonic Matter Framework integration
131131

132132
GERM belongs to two constellations. The Listening Stack is how a sound is
133-
heard, remembered, and re-listened; the **Sonic Matter Stack** is how sound is
134-
described *as matter* — its provenance, its measurements, its granular and
135-
spectral operations, and the observations that modulate it. GERM keeps those
136-
components distinct while making their contracts usable inside the cultivation
137-
graph:
133+
heard, remembered, and re-listened; the **Sonic Matter Framework** joins three
134+
distinct projects around sound described *as matter*: MASA supplies the
135+
descriptive protocol, Cosmoaudition supplies attributed observations and
136+
modulation decisions, and GERM cultivates sound while retaining provenance and
137+
lineage. GERM keeps those roles distinct while making their contracts usable
138+
inside its cultivation graph:
138139

139140
| Component | Version / contract | Boundary in GERM |
140141
| --- | --- | --- |
141142
| [MASA](https://github.com/sonicfieldlabs/MASA) | 0.1.0 (MIT) | Optional descriptive JSON sidecars under `output/masa/`, citing the published canonical schema at `masa.sonicfield.org`. They never replace Sonic Lineage or turn a successful render into a failure. |
142143
| MASA Processing | `masa-processing-request` 0.1.0 | Every Micro module declares a granular or spectral operation in MASA's engine-neutral terms. GERM states the intention and binds no DSP library. |
143-
| MATERIA || Matter Analysis provides a bounded local analyzer informed by the shared measured / inferred / unavailable distinction; it is not a claim of listening. |
144+
| GERM Matter Analysis || A bounded local analyzer informed by the shared measured / inferred / unavailable distinction; it is not a claim of listening or a fourth framework project. |
144145
| [Cosmoaudition](https://github.com/sonicfieldlabs/cosmoaudition) | `cosmo/modulation/v0.1` | A loopback-only, response-bounded HTTP bridge reads source status, snapshots, and modulation frames. GERM never contacts observation providers directly. |
145146

146147
### Micro modules as MASA processing operations
@@ -173,9 +174,13 @@ the route remains inactive until its state is explicit.
173174
`GET /cosmoaudition/frame` reads one modulation frame and resolves it into
174175
GERM routes. It reads the frame's `controls`, never its bare `values` map:
175176
a value arrives with the decision that produced it, or it does not arrive.
176-
Withheld routes and emitted absences are reported rather than dropped. Server-
177-
Sent Events are deliberately not bridged, because this bridge is a bounded
178-
request/response client; poll `/cosmoaudition/frame` instead.
177+
Out-of-range or structurally invalid controls are withheld with an explicit
178+
bridge reason. Frame-level `signals` and `sources` travel beside the routes so
179+
their source, unit, sphere, epistemic status, and temporal character remain
180+
traceable through `signalId`. Withheld routes and emitted absences are reported
181+
rather than dropped. Server-Sent Events are deliberately not bridged, because
182+
this bridge is a bounded request/response client; poll `/cosmoaudition/frame`
183+
instead.
179184

180185
## Listening Stack integration
181186

apps/macos/script/build_and_run.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ APP_NAME="germ"
66
EXECUTABLE_NAME="germ-macos"
77
BUNDLE_ID="org.sonicfield.germ"
88
MIN_SYSTEM_VERSION="13.0"
9-
MARKETING_VERSION="0.4.0"
10-
BUNDLE_VERSION="6"
9+
MARKETING_VERSION="0.4.1"
10+
BUNDLE_VERSION="7"
1111

1212
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
1313
# Keep the runnable bundle where repository users expect to find apps. The

dashboard/static/app.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3487,7 +3487,7 @@ async function renameFile(audioPath, metadataPath, newStem) {
34873487
async function deleteSelectedFiles() {
34883488
const items = [...rackSelectedKeys].map(rackItemByKey).filter(Boolean);
34893489
if (!items.length) return;
3490-
if (!confirm(`Permanently delete ${items.length} selected sound file(s) from disk? This cannot be undone.`)) return;
3490+
if (!confirm(`Permanently delete ${items.length} selected sound(s) and their linked local metadata/MASA companions from disk? Akousmata-linked sounds are preserved. This cannot be undone.`)) return;
34913491

34923492
beginWork("Deleting Files", `${items.length} items`);
34933493
try {
@@ -3514,7 +3514,7 @@ async function deleteSelectedFiles() {
35143514

35153515
async function deleteSingleFile(audioPath, metadataPath) {
35163516
const filename = audioPath.split("/").pop();
3517-
if (!confirm(`Permanently delete "${filename}" from disk? This cannot be undone.`)) return;
3517+
if (!confirm(`Permanently delete "${filename}" and its linked local metadata/MASA companions from disk? Akousmata-linked sounds are preserved. This cannot be undone.`)) return;
35183518

35193519
beginWork("Deleting File", filename);
35203520
try {

docs/api_reference.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -864,8 +864,15 @@ The server only reveals files inside the configured output directory.
864864
}
865865
```
866866

867-
Renames the audio file (and metadata file, when given) to a sanitized stem and rewrites
868-
the path/id fields inside the metadata JSON, rolling back on failure.
867+
Renames the audio file (and its actual metadata companion, when one exists) to a sanitized stem and
868+
rewrites storage paths inside the metadata JSON. Canonical `sound_id`, lineage,
869+
and Earworm identifiers remain stable. Same-name metadata is discovered even
870+
when the caller omits it, and only a metadata object that actually identifies
871+
the audio can be used. Matching deferred-latent locators are rewritten. A
872+
verified written MASA sidecar is refreshed transactionally in place; a failed
873+
companion write rolls the rename back. Sounds remembered in Akousmata, or with
874+
an unresolved requested memory write, are refused because moving their audio
875+
could break the shared record's immutable file locator.
869876

870877
## POST /files/delete
871878

@@ -880,4 +887,10 @@ the path/id fields inside the metadata JSON, rolling back on failure.
880887
}
881888
```
882889

883-
Best-effort bulk delete of output files (max 500 items per request).
890+
Best-effort bulk delete of audio output files and their explicitly supplied,
891+
verified metadata companions (max 500 items per request). The primary path must
892+
use a supported audio extension. If companion metadata exists, its exact path
893+
is required; a verified written MASA sidecar is then deleted with that local
894+
record. Remembered or unresolved requested Akousmata are refused because their
895+
shared `file://` locator must remain valid. Unrelated JSON and unresolved
896+
sidecar evidence are preserved.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "germ"
3-
version = "0.4.0"
3+
version = "0.4.1"
44
description = "Local-first modular laboratory for generative microsound, Stable Audio workflows, and lineage-aware cultivation."
55
readme = "README.md"
66
license = "MPL-2.0"

server/cosmoaudition.py

Lines changed: 115 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,33 @@ def _reject_nonfinite_json(value: str) -> None:
5050
raise ValueError(f"non-finite JSON constant: {value}")
5151

5252

53+
def _decode_json_integer(value: str) -> int | float:
54+
"""Keep ordinary integers exact and accept finite JavaScript wire numbers.
55+
56+
Cosmoaudition serializes IEEE-754 numbers with ``JSON.stringify``. A value
57+
such as the Bitcoin hashrate can therefore arrive with an integer spelling
58+
outside GERM's signed-64-bit persistence boundary even though the producer
59+
already represents it as a finite float. Preserve normal JSON integers as
60+
integers, but materialize those wider wire values as finite floats so one
61+
observation cannot invalidate the complete modulation frame.
62+
"""
63+
64+
decoded = int(value)
65+
if -(2**63) <= decoded < 2**63:
66+
return decoded
67+
widened = float(value)
68+
if not math.isfinite(widened):
69+
raise ValueError("JSON integer exceeds the finite-number boundary")
70+
return widened
71+
72+
5373
def _decode_json_object(raw: bytes) -> dict[str, Any]:
5474
try:
55-
value = json.loads(raw, parse_constant=_reject_nonfinite_json)
75+
value = json.loads(
76+
raw,
77+
parse_constant=_reject_nonfinite_json,
78+
parse_int=_decode_json_integer,
79+
)
5680
except (UnicodeError, json.JSONDecodeError, ValueError, RecursionError) as exc:
5781
raise CosmoauditionBridgeError("Cosmoaudition returned invalid JSON") from exc
5882
if not isinstance(value, dict):
@@ -187,6 +211,60 @@ def status(self) -> dict[str, Any]:
187211
#: `skipped` and `refused` carry none; `held` and `uncertainty` carry one that
188212
#: must keep its status when it travels.
189213
EXECUTABLE_FRAME_STATUSES = frozenset({"applied", "held", "uncertainty"})
214+
WITHHELD_FRAME_STATUSES = frozenset({"skipped", "refused"})
215+
216+
217+
def _finite_frame_number(value: Any) -> bool:
218+
if not isinstance(value, (int, float)) or isinstance(value, bool):
219+
return False
220+
try:
221+
return math.isfinite(value)
222+
except OverflowError:
223+
# JSON integers are unbounded in Python but not in Cosmoaudition's
224+
# JavaScript runtime. They cannot become executable control values.
225+
return False
226+
227+
228+
def _frame_object_list(
229+
frame: dict[str, Any],
230+
key: str,
231+
*,
232+
required: bool = False,
233+
) -> list[dict[str, Any]]:
234+
value = frame.get(key)
235+
if value is None:
236+
if required:
237+
raise CosmoauditionBridgeError(f"Cosmoaudition frame is missing its {key}")
238+
return []
239+
if not isinstance(value, list) or any(not isinstance(item, dict) for item in value):
240+
raise CosmoauditionBridgeError(f"Cosmoaudition frame has invalid {key}")
241+
return list(value)
242+
243+
244+
def _control_bridge_reason(control: dict[str, Any], status: Any) -> str | None:
245+
if status not in EXECUTABLE_FRAME_STATUSES:
246+
return None if status in WITHHELD_FRAME_STATUSES else "invalid-status"
247+
if any(
248+
not isinstance(control.get(key), str) or not control[key].strip()
249+
for key in ("mappingId", "signalId", "target")
250+
):
251+
return "invalid-route-identity"
252+
253+
output_value = control.get("outputValue")
254+
if not _finite_frame_number(output_value):
255+
return "invalid-output-value"
256+
output_range = control.get("outputRange")
257+
if (
258+
not isinstance(output_range, (list, tuple))
259+
or len(output_range) != 2
260+
or not all(_finite_frame_number(value) for value in output_range)
261+
or output_range[0] == output_range[1]
262+
):
263+
return "invalid-output-range"
264+
lower, upper = sorted(output_range)
265+
if not lower <= output_value <= upper:
266+
return "output-outside-range"
267+
return None
190268

191269

192270
def modulation_routes_from_frame(frame: dict[str, Any]) -> dict[str, Any]:
@@ -201,32 +279,51 @@ def modulation_routes_from_frame(frame: dict[str, Any]) -> dict[str, Any]:
201279
went missing is a fact about the frame.
202280
"""
203281

204-
controls = frame.get("controls")
205-
if not isinstance(controls, list):
206-
raise CosmoauditionBridgeError("Cosmoaudition frame is missing its controls")
282+
if frame.get("contract") != COSMOAUDITION_MODULATION_CONTRACT:
283+
raise CosmoauditionBridgeError(
284+
"Cosmoaudition frame does not declare "
285+
f"{COSMOAUDITION_MODULATION_CONTRACT}"
286+
)
287+
288+
controls = _frame_object_list(frame, "controls", required=True)
289+
signals = _frame_object_list(frame, "signals")
290+
sources = _frame_object_list(frame, "sources")
207291

208292
routes: list[dict[str, Any]] = []
209293
withheld: list[dict[str, Any]] = []
210294
for control in controls:
211-
if not isinstance(control, dict):
212-
continue
213295
status = control.get("status")
296+
output_value = control.get("outputValue")
297+
bridge_reason = _control_bridge_reason(control, status)
298+
executable = status in EXECUTABLE_FRAME_STATUSES and bridge_reason is None
214299
entry = {
215300
"target": control.get("target"),
216301
"mappingId": control.get("mappingId"),
217302
"signalId": control.get("signalId"),
303+
"layer": control.get("layer"),
218304
"status": status,
219305
"reason": control.get("reason"),
220-
"value": control.get("value"),
221-
"unit": control.get("unit"),
222-
"attribution": control.get("attribution"),
306+
"bridgeReason": bridge_reason,
307+
# ``value`` is GERM's consumer-facing route value. Cosmoaudition's
308+
# modulation contract names the same field ``outputValue``; keep
309+
# both names so the crosswalk is explicit and lossless.
310+
"value": output_value if executable else None,
311+
"outputValue": output_value if executable else None,
312+
"inputValue": control.get("inputValue"),
313+
"normalizedInput": control.get("normalizedInput"),
314+
"rawNormalizedInput": control.get("rawNormalizedInput"),
315+
"mappingAmount": control.get("mappingAmount"),
316+
"outputRange": control.get("outputRange"),
317+
"curve": control.get("curve"),
318+
"smoothingMs": control.get("smoothingMs"),
319+
"missingData": control.get("missingData"),
320+
"confidence": control.get("confidence"),
321+
"epistemicNote": control.get("epistemicNote"),
223322
}
224-
if status in EXECUTABLE_FRAME_STATUSES and isinstance(
225-
control.get("value"), (int, float)
226-
) and not isinstance(control.get("value"), bool):
323+
if executable:
227324
routes.append(entry)
228325
else:
229-
withheld.append({**entry, "value": None})
326+
withheld.append(entry)
230327

231328
absences = frame.get("absences")
232329
return {
@@ -237,6 +334,11 @@ def modulation_routes_from_frame(frame: dict[str, Any]) -> dict[str, Any]:
237334
"originMode": frame.get("originMode"),
238335
"routes": routes,
239336
"withheld": withheld,
337+
# Preserve frame-level source evidence. `signalId` is the join key; the
338+
# input observation's unit/status must not be mistaken for the routed
339+
# output parameter's unit/status by flattening the two objects together.
340+
"signals": signals,
341+
"sources": sources,
240342
"absences": absences if isinstance(absences, list) else [],
241343
"attribution": frame.get("attribution") if isinstance(frame.get("attribution"), list) else [],
242344
"masaRecordHref": frame.get("masaRecordHref"),

server/identity.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
PRODUCT_NAME = "germ"
55
PRODUCT_DESCRIPTION = "open-source modular lab for generative microsound"
6-
__version__ = "0.4.0"
6+
__version__ = "0.4.1"
77
LEGACY_ENGINE_NAME = "Germinator"
88
SOUND_MATTER_CONCEPT = "sound_matter"
99
SOUND_MATTER_SCALES = ["micro", "meso", "macro"]

0 commit comments

Comments
 (0)