Skip to content

Commit cbbdd70

Browse files
committed
Release v0.2.5 backend audit and hardening
1 parent 59fa176 commit cbbdd70

57 files changed

Lines changed: 3679 additions & 1198 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ GERM_MAX_IMAGE_MB=8
2525
# Cloud image analysis is disabled by default. Set this to 1 only when you
2626
# intentionally want /image-to-audio/analyze vision mode to send images to Gemini.
2727
GERM_ENABLE_CLOUD_VISION=0
28+
GERM_GEMINI_MODEL=gemini-3.5-flash
2829
GEMINI_API_KEY=
2930
GOOGLE_API_KEY=
3031

CHANGELOG.md

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

3+
## 0.2.5 — Backend integrity and integration hardening
4+
5+
- Hardened request, persisted JSON, path, upload, WAV, and provider artifact
6+
validation across generation, editing, control, listener, session, Micro,
7+
wavetable, library, and file workflows.
8+
- Made provider execution, cancellation, job retention, caches, metadata,
9+
lineage, Akousmata companion writes, and multi-step render transactions
10+
bounded and failure-safe.
11+
- Updated Stability and Gemini integrations to their current contracts,
12+
strengthened Python and MLX provider output checks, and aligned the test
13+
client dependency with Starlette's `httpx2` backend.
14+
- Improved dashboard data escaping, native macOS daemon lifecycle handling,
15+
launch scripts, and regression coverage for integration boundaries.
16+
317
## 0.2.0 — Listening-informed cultivation
418

519
- Added OÍDA re-listening, prompt derivation, immutable evidence summaries,

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.2.0"
9-
date-released: "2026-07-02"
8+
version: "0.2.5"
9+
date-released: "2026-07-16"
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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ 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.2.0`.
11+
Current release: `0.2.5`.
1212

1313
GERM is an independent Sonic Field Labs project. It can use Stable Audio 3
1414
providers, but it is not an official Stability AI product.

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# GERM Roadmap
22

3-
GERM 0.2.0 is a public alpha and open research release. This roadmap names the
3+
GERM 0.2.5 is a public alpha and open research release. This roadmap names the
44
work in view without promising dates or production stability.
55

66
## Working Now

apps/macos/Sources/GermMacOS/Services/DaemonSupervisor.swift

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -89,34 +89,36 @@ final class DaemonSupervisor {
8989
errorPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
9090
self?.emitLines(from: handle.availableData)
9191
}
92-
proc.terminationHandler = { [weak self] process in
92+
proc.terminationHandler = { [weak self] terminatedProcess in
9393
DispatchQueue.main.async {
94-
self?.onExit?(process.terminationStatus)
95-
self?.cleanup()
94+
guard self?.process === terminatedProcess else { return }
95+
self?.onExit?(terminatedProcess.terminationStatus)
96+
self?.cleanup(expected: terminatedProcess)
9697
}
9798
}
9899

100+
process = proc
101+
self.outputPipe = outputPipe
102+
self.errorPipe = errorPipe
99103
do {
100104
try proc.run()
101105
} catch {
102-
cleanup()
106+
cleanup(expected: proc)
103107
throw DaemonSupervisorError.launchFailed(error.localizedDescription)
104108
}
105-
106-
process = proc
107-
self.outputPipe = outputPipe
108-
self.errorPipe = errorPipe
109109
onLogLine?("Started germ daemon from \(root.path)")
110110
}
111111

112112
func stop() {
113-
guard let process else { return }
114-
process.terminate()
115-
cleanup()
113+
guard let managedProcess = process else { return }
114+
managedProcess.terminationHandler = nil
115+
managedProcess.terminate()
116+
cleanup(expected: managedProcess)
116117
onLogLine?("Stopped managed daemon")
117118
}
118119

119-
private func cleanup() {
120+
private func cleanup(expected: Process? = nil) {
121+
if let expected, process !== expected { return }
120122
outputPipe?.fileHandleForReading.readabilityHandler = nil
121123
errorPipe?.fileHandleForReading.readabilityHandler = nil
122124
outputPipe = nil

apps/macos/Sources/GermMacOS/Services/ShellStore.swift

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ final class ShellStore: ObservableObject {
6666
UserDefaults.standard.string(forKey: "germAccentHex")
6767
)
6868
let stored = UserDefaults.standard.string(forKey: "germDaemonBaseURL")
69-
daemonBaseURL = (stored?.isEmpty == false ? stored! : "http://127.0.0.1:5178")
69+
let cleanedStored = stored?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
70+
daemonBaseURL = cleanedStored.isEmpty ? "http://127.0.0.1:5178" : cleanedStored
7071
// Quit stops only the daemon this shell started; externally started
7172
// daemons are observed, never owned. The managed process helper also
7273
// watches this app's PID so an unexpected exit cannot orphan uvicorn.
@@ -94,8 +95,8 @@ final class ShellStore: ObservableObject {
9495
}
9596

9697
var dashboardURL: String {
97-
let base = daemonBaseURL.hasSuffix("/") ? String(daemonBaseURL.dropLast()) : daemonBaseURL
98-
return "\(base)/dashboard"
98+
guard let baseURL = normalizedDaemonURL else { return "about:blank" }
99+
return baseURL.appendingPathComponent("dashboard").absoluteString
99100
}
100101

101102
var preferredColorScheme: ColorScheme {
@@ -147,8 +148,10 @@ final class ShellStore: ObservableObject {
147148
}
148149

149150
func refresh() async {
150-
guard let url = URL(string: "\(daemonBaseURL)/health") else {
151+
guard let url = normalizedDaemonURL?.appendingPathComponent("health") else {
151152
daemonOnline = false
153+
health = nil
154+
errorMessage = "Daemon base URL must be a valid HTTP or HTTPS URL."
152155
return
153156
}
154157
do {
@@ -180,8 +183,16 @@ final class ShellStore: ObservableObject {
180183
isStartingDaemon = true
181184
defer { isStartingDaemon = false }
182185
do {
183-
let port = URL(string: daemonBaseURL)?.port ?? 5178
184-
try supervisor.start(port: port)
186+
guard let url = normalizedDaemonURL,
187+
url.scheme?.lowercased() == "http",
188+
let host = url.host,
189+
["localhost", "127.0.0.1", "::1"].contains(host.lowercased()) else {
190+
throw DaemonSupervisorError.launchFailed(
191+
"Managed startup requires a local HTTP base URL."
192+
)
193+
}
194+
let port = url.port ?? 80
195+
try supervisor.start(host: host, port: port)
185196
managedDaemonRunning = true
186197
// uvicorn needs a moment before /health responds.
187198
for _ in 0..<10 {
@@ -238,6 +249,27 @@ final class ShellStore: ObservableObject {
238249
}
239250
}
240251

252+
private var normalizedDaemonURL: URL? {
253+
let trimmed = daemonBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)
254+
guard var components = URLComponents(string: trimmed),
255+
let scheme = components.scheme?.lowercased(),
256+
["http", "https"].contains(scheme),
257+
components.host?.isEmpty == false,
258+
components.user == nil,
259+
components.password == nil,
260+
components.query == nil,
261+
components.fragment == nil,
262+
components.path.isEmpty || components.path == "/" else {
263+
return nil
264+
}
265+
var path = components.path
266+
while path.count > 1 && path.hasSuffix("/") {
267+
path.removeLast()
268+
}
269+
components.path = path
270+
return components.url
271+
}
272+
241273
/// The shell stops only daemons it started itself; an externally started
242274
/// daemon keeps running when the app quits.
243275
nonisolated func shutdown() {

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.2.0"
10-
BUNDLE_VERSION="1"
9+
MARKETING_VERSION="0.2.5"
10+
BUNDLE_VERSION="2"
1111

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

clients/python_client.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ def __init__(self, base_url: str = DEFAULT_BASE_URL, timeout: float = 600.0) ->
1414
self.base_url = base_url.rstrip("/")
1515
self.client = httpx.Client(timeout=timeout)
1616

17+
def close(self) -> None:
18+
self.client.close()
19+
20+
def __enter__(self) -> "GermClient":
21+
return self
22+
23+
def __exit__(self, *_exc_info: object) -> None:
24+
self.close()
25+
1726
def health(self) -> dict:
1827
return self.client.get(f"{self.base_url}/health").raise_for_status().json()
1928

@@ -131,9 +140,9 @@ def main() -> int:
131140
parser.add_argument("--model", default="mock-sine")
132141
args = parser.parse_args()
133142

134-
client = GermClient(args.base_url)
135-
print(client.health())
136-
print(client.generate(args.prompt, provider=args.provider, model=args.model))
143+
with GermClient(args.base_url) as client:
144+
print(client.health())
145+
print(client.generate(args.prompt, provider=args.provider, model=args.model))
137146
return 0
138147

139148

dashboard/static/app.js

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2046,7 +2046,9 @@ function updateModels(preserve = true) {
20462046
const provider = $("provider").value;
20472047
const previous = $("model").value;
20482048
const models = providerModels[provider] || [];
2049-
$("model").innerHTML = models.map((model) => `<option value="${model}">${model}</option>`).join("");
2049+
$("model").replaceChildren(
2050+
...models.map((model) => new Option(String(model), String(model))),
2051+
);
20502052
if (preserve && models.includes(previous)) $("model").value = previous;
20512053
$("activeProvider").textContent = provider;
20522054
$("activeModel").textContent = $("model").value || "-";
@@ -3576,7 +3578,10 @@ function normalizeTimeState(raw = {}) {
35763578
const bpm = Math.min(300, Math.max(20, Number(raw?.bpm) || defaults.bpm));
35773579
const bars = Math.min(128, Math.max(1, Math.round(Number(raw?.bars) || defaults.bars)));
35783580
const ppq = Math.min(3840, Math.max(24, Math.round(Number(raw?.ppq) || defaults.ppq)));
3579-
const sampleRate = Math.round(Number(raw?.sampleRate ?? raw?.sample_rate) || defaults.sampleRate);
3581+
// Backend time renders are fixed to SAME's 44.1 kHz clock. Session data
3582+
// created by older builds may contain another rate, so normalize it here
3583+
// before the clock reaches the API.
3584+
const sampleRate = 44100;
35803585
const snapDivision = ["1/4", "1/8", "1/16", "1/32", "triplet"].includes(raw?.snapDivision ?? raw?.snap_division)
35813586
? (raw.snapDivision ?? raw.snap_division)
35823587
: defaults.snapDivision;
@@ -3599,34 +3604,41 @@ function timeClockDerived(clock = timeState) {
35993604
const normalized = normalizeTimeState(clock);
36003605
const secondsPerBeat = 60 / normalized.bpm;
36013606
const totalBeats = normalized.bars * normalized.timeSignature.beatsPerBar;
3602-
const loopSeconds = totalBeats * secondsPerBeat;
36033607
const ticksPerBar = normalized.timeSignature.beatsPerBar * normalized.ppq;
36043608
const totalTicks = normalized.bars * ticksPerBar;
3609+
const loopStartTick = Math.min(totalTicks - 1, normalized.loopStartTick);
3610+
const requestedLoopEnd = Number(normalized.loopEndTick);
3611+
const loopEndTick = Number.isFinite(requestedLoopEnd) && requestedLoopEnd > loopStartTick
3612+
? Math.min(totalTicks, Math.round(requestedLoopEnd))
3613+
: totalTicks;
3614+
const loopSeconds = ((loopEndTick - loopStartTick) / normalized.ppq) * secondsPerBeat;
36053615
return {
36063616
secondsPerBeat,
36073617
totalBeats,
36083618
loopSeconds,
36093619
loopSamples: Math.round(loopSeconds * normalized.sampleRate),
36103620
ticksPerBar,
36113621
totalTicks,
3612-
loopStartTick: normalized.loopStartTick,
3613-
loopEndTick: normalized.loopEndTick || totalTicks,
3622+
loopStartTick,
3623+
loopEndTick,
36143624
};
36153625
}
36163626

36173627
function timeStateApiClock() {
3628+
const normalized = normalizeTimeState(timeState);
3629+
const derived = timeClockDerived(normalized);
36183630
return {
3619-
enabled: Boolean(timeState.enabled),
3620-
bpm: Number(timeState.bpm),
3621-
beats_per_bar: Number(timeState.timeSignature.beatsPerBar),
3622-
beat_unit: Number(timeState.timeSignature.beatUnit),
3623-
bars: Number(timeState.bars),
3624-
ppq: Number(timeState.ppq),
3625-
sample_rate: Number(timeState.sampleRate),
3626-
snap_division: timeState.snapDivision,
3627-
swing: Number(timeState.swing) || 0,
3628-
loop_start_tick: Number(timeState.loopStartTick) || 0,
3629-
loop_end_tick: timeState.loopEndTick || null,
3631+
enabled: Boolean(normalized.enabled),
3632+
bpm: normalized.bpm,
3633+
beats_per_bar: normalized.timeSignature.beatsPerBar,
3634+
beat_unit: normalized.timeSignature.beatUnit,
3635+
bars: normalized.bars,
3636+
ppq: normalized.ppq,
3637+
sample_rate: normalized.sampleRate,
3638+
snap_division: normalized.snapDivision,
3639+
swing: normalized.swing,
3640+
loop_start_tick: derived.loopStartTick,
3641+
loop_end_tick: derived.loopEndTick,
36303642
};
36313643
}
36323644

@@ -7963,19 +7975,20 @@ function renderSnapshotLibrary() {
79637975
const nodeCount = record.nodes?.length || 0;
79647976
const edgeCount = record.edges?.length || 0;
79657977
const date = record.createdAt ? new Date(record.createdAt).toLocaleString() : "—";
7966-
return `<div class="snapshot-card" data-snapshot-id="${record.id}">
7967-
<button class="snapshot-card-fav${isFav ? " is-fav" : ""}" data-action="snapshot-toggle-fav" data-snapshot-id="${record.id}" title="${isFav ? "Unfavorite" : "Favorite"}" type="button">
7978+
const safeRecordId = escapeHtml(record.id);
7979+
return `<div class="snapshot-card" data-snapshot-id="${safeRecordId}">
7980+
<button class="snapshot-card-fav${isFav ? " is-fav" : ""}" data-action="snapshot-toggle-fav" data-snapshot-id="${safeRecordId}" title="${isFav ? "Unfavorite" : "Favorite"}" type="button">
79687981
<svg viewBox="0 0 24 24" width="14" height="14" fill="${isFav ? "currentColor" : "none"}" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01z"/></svg>
79697982
</button>
7970-
<div class="snapshot-card-info" data-action="snapshot-load" data-snapshot-id="${record.id}">
7983+
<div class="snapshot-card-info" data-action="snapshot-load" data-snapshot-id="${safeRecordId}">
79717984
<div class="snapshot-card-name">${escapeHtml(record.name || record.id)}</div>
7972-
<div class="snapshot-card-meta">${nodeCount} nodes · ${edgeCount} edges · ${date}</div>
7985+
<div class="snapshot-card-meta">${escapeHtml(nodeCount)} nodes · ${escapeHtml(edgeCount)} edges · ${escapeHtml(date)}</div>
79737986
</div>
79747987
<div class="snapshot-card-actions">
7975-
<button data-action="snapshot-rename" data-snapshot-id="${record.id}" title="Rename" type="button">
7988+
<button data-action="snapshot-rename" data-snapshot-id="${safeRecordId}" title="Rename" type="button">
79767989
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
79777990
</button>
7978-
<button class="snapshot-delete" data-action="snapshot-delete" data-snapshot-id="${record.id}" title="Delete" type="button">
7991+
<button class="snapshot-delete" data-action="snapshot-delete" data-snapshot-id="${safeRecordId}" title="Delete" type="button">
79797992
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
79807993
</button>
79817994
</div>
@@ -8076,7 +8089,7 @@ function renderSessionLibrary() {
80768089
return `<div class="snapshot-card" data-session-id="${escapeHtml(session.id)}">
80778090
<div class="snapshot-card-info" data-action="session-load" data-session-id="${escapeHtml(session.id)}">
80788091
<div class="snapshot-card-name">${escapeHtml(session.name || session.id)}</div>
8079-
<div class="snapshot-card-meta">${session.node_count} nodes · ${session.edge_count} edges · ${date}</div>
8092+
<div class="snapshot-card-meta">${escapeHtml(session.node_count)} nodes · ${escapeHtml(session.edge_count)} edges · ${escapeHtml(date)}</div>
80808093
</div>
80818094
<div class="snapshot-card-actions">
80828095
<button class="snapshot-delete" data-action="session-delete" data-session-id="${escapeHtml(session.id)}" title="Delete session" aria-label="Delete session" type="button">
@@ -16160,7 +16173,9 @@ async function canvasAcceptCandidate(candidateId, acceptAs) {
1616016173
if (!candidate || !candidateAsset) return;
1616116174
const sourceNode = canvasNodes.find((node) => node.id === candidate.sourceNodeId) || canvasSelectedNode();
1616216175
if (acceptAs === "branch" || !sourceNode || sourceNode.type !== "sound") {
16163-
const sourceEl = sourceNode ? document.querySelector(`.canvas-node[data-node-id="${sourceNode.id}"]`) : null;
16176+
const sourceEl = sourceNode
16177+
? document.querySelector(`.canvas-node[data-node-id="${CSS.escape(sourceNode.id)}"]`)
16178+
: null;
1616416179
const sourceH = sourceEl ? sourceEl.offsetHeight : (sourceNode?.height || 262);
1616516180
let newX = (sourceNode?.x || 210) + 48;
1616616181
let newY = (sourceNode?.y || 110) + 260;

0 commit comments

Comments
 (0)