Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ GERM_MAX_IMAGE_MB=8
# Cloud image analysis is disabled by default. Set this to 1 only when you
# intentionally want /image-to-audio/analyze vision mode to send images to Gemini.
GERM_ENABLE_CLOUD_VISION=0
GERM_GEMINI_MODEL=gemini-3.5-flash
GEMINI_API_KEY=
GOOGLE_API_KEY=

Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## 0.2.5 — Backend integrity and integration hardening

- Hardened request, persisted JSON, path, upload, WAV, and provider artifact
validation across generation, editing, control, listener, session, Micro,
wavetable, library, and file workflows.
- Made provider execution, cancellation, job retention, caches, metadata,
lineage, Akousmata companion writes, and multi-step render transactions
bounded and failure-safe.
- Updated Stability and Gemini integrations to their current contracts,
strengthened Python and MLX provider output checks, and aligned the test
client dependency with Starlette's `httpx2` backend.
- Improved dashboard data escaping, native macOS daemon lifecycle handling,
launch scripts, and regression coverage for integration boundaries.

## 0.2.0 — Listening-informed cultivation

- Added OÍDA re-listening, prompt derivation, immutable evidence summaries,
Expand Down
4 changes: 2 additions & 2 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ authors:
- family-names: "Isaza"
given-names: "eme"
affiliation: "Sonic Field Labs"
version: "0.2.0"
date-released: "2026-07-02"
version: "0.2.5"
date-released: "2026-07-16"
license: "MPL-2.0"
repository-code: "https://github.com/sonicfieldlabs/germ"
abstract: "germ is a local generative microsound environment whose generated sounds retain prompts, parents, mutations, listening metadata, and Earworm-compatible lineage."
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ listened to, and traced through lineage. A listening from Oída can become a
prompt or source in GERM; a successful render can become a descendant in
Akousmata and return to Oída for another listening.

Current release: `0.2.0`.
Current release: `0.2.5`.

GERM is an independent Sonic Field Labs project. It can use Stable Audio 3
providers, but it is not an official Stability AI product.
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# GERM Roadmap

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

## Working Now
Expand Down
26 changes: 14 additions & 12 deletions apps/macos/Sources/GermMacOS/Services/DaemonSupervisor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,34 +89,36 @@ final class DaemonSupervisor {
errorPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
self?.emitLines(from: handle.availableData)
}
proc.terminationHandler = { [weak self] process in
proc.terminationHandler = { [weak self] terminatedProcess in
DispatchQueue.main.async {
self?.onExit?(process.terminationStatus)
self?.cleanup()
guard self?.process === terminatedProcess else { return }
self?.onExit?(terminatedProcess.terminationStatus)
self?.cleanup(expected: terminatedProcess)
}
}

process = proc
self.outputPipe = outputPipe
self.errorPipe = errorPipe
do {
try proc.run()
} catch {
cleanup()
cleanup(expected: proc)
throw DaemonSupervisorError.launchFailed(error.localizedDescription)
}

process = proc
self.outputPipe = outputPipe
self.errorPipe = errorPipe
onLogLine?("Started germ daemon from \(root.path)")
}

func stop() {
guard let process else { return }
process.terminate()
cleanup()
guard let managedProcess = process else { return }
managedProcess.terminationHandler = nil
managedProcess.terminate()
cleanup(expected: managedProcess)
onLogLine?("Stopped managed daemon")
}

private func cleanup() {
private func cleanup(expected: Process? = nil) {
if let expected, process !== expected { return }
outputPipe?.fileHandleForReading.readabilityHandler = nil
errorPipe?.fileHandleForReading.readabilityHandler = nil
outputPipe = nil
Expand Down
44 changes: 38 additions & 6 deletions apps/macos/Sources/GermMacOS/Services/ShellStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ final class ShellStore: ObservableObject {
UserDefaults.standard.string(forKey: "germAccentHex")
)
let stored = UserDefaults.standard.string(forKey: "germDaemonBaseURL")
daemonBaseURL = (stored?.isEmpty == false ? stored! : "http://127.0.0.1:5178")
let cleanedStored = stored?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
daemonBaseURL = cleanedStored.isEmpty ? "http://127.0.0.1:5178" : cleanedStored
// Quit stops only the daemon this shell started; externally started
// daemons are observed, never owned. The managed process helper also
// watches this app's PID so an unexpected exit cannot orphan uvicorn.
Expand Down Expand Up @@ -94,8 +95,8 @@ final class ShellStore: ObservableObject {
}

var dashboardURL: String {
let base = daemonBaseURL.hasSuffix("/") ? String(daemonBaseURL.dropLast()) : daemonBaseURL
return "\(base)/dashboard"
guard let baseURL = normalizedDaemonURL else { return "about:blank" }
return baseURL.appendingPathComponent("dashboard").absoluteString
}

var preferredColorScheme: ColorScheme {
Expand Down Expand Up @@ -147,8 +148,10 @@ final class ShellStore: ObservableObject {
}

func refresh() async {
guard let url = URL(string: "\(daemonBaseURL)/health") else {
guard let url = normalizedDaemonURL?.appendingPathComponent("health") else {
daemonOnline = false
health = nil
errorMessage = "Daemon base URL must be a valid HTTP or HTTPS URL."
return
}
do {
Expand Down Expand Up @@ -180,8 +183,16 @@ final class ShellStore: ObservableObject {
isStartingDaemon = true
defer { isStartingDaemon = false }
do {
let port = URL(string: daemonBaseURL)?.port ?? 5178
try supervisor.start(port: port)
guard let url = normalizedDaemonURL,
url.scheme?.lowercased() == "http",
let host = url.host,
["localhost", "127.0.0.1", "::1"].contains(host.lowercased()) else {
throw DaemonSupervisorError.launchFailed(
"Managed startup requires a local HTTP base URL."
)
}
let port = url.port ?? 80
try supervisor.start(host: host, port: port)
managedDaemonRunning = true
// uvicorn needs a moment before /health responds.
for _ in 0..<10 {
Expand Down Expand Up @@ -238,6 +249,27 @@ final class ShellStore: ObservableObject {
}
}

private var normalizedDaemonURL: URL? {
let trimmed = daemonBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)
guard var components = URLComponents(string: trimmed),
let scheme = components.scheme?.lowercased(),
["http", "https"].contains(scheme),
components.host?.isEmpty == false,
components.user == nil,
components.password == nil,
components.query == nil,
components.fragment == nil,
components.path.isEmpty || components.path == "/" else {
return nil
}
var path = components.path
while path.count > 1 && path.hasSuffix("/") {
path.removeLast()
}
components.path = path
return components.url
}

/// The shell stops only daemons it started itself; an externally started
/// daemon keeps running when the app quits.
nonisolated func shutdown() {
Expand Down
4 changes: 2 additions & 2 deletions apps/macos/script/build_and_run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ APP_NAME="germ"
EXECUTABLE_NAME="germ-macos"
BUNDLE_ID="org.sonicfield.germ"
MIN_SYSTEM_VERSION="13.0"
MARKETING_VERSION="0.2.0"
BUNDLE_VERSION="1"
MARKETING_VERSION="0.2.5"
BUNDLE_VERSION="2"

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Keep the runnable bundle where repository users expect to find apps. The
Expand Down
15 changes: 12 additions & 3 deletions clients/python_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ def __init__(self, base_url: str = DEFAULT_BASE_URL, timeout: float = 600.0) ->
self.base_url = base_url.rstrip("/")
self.client = httpx.Client(timeout=timeout)

def close(self) -> None:
self.client.close()

def __enter__(self) -> "GermClient":
return self

def __exit__(self, *_exc_info: object) -> None:
self.close()

def health(self) -> dict:
return self.client.get(f"{self.base_url}/health").raise_for_status().json()

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

client = GermClient(args.base_url)
print(client.health())
print(client.generate(args.prompt, provider=args.provider, model=args.model))
with GermClient(args.base_url) as client:
print(client.health())
print(client.generate(args.prompt, provider=args.provider, model=args.model))
return 0


Expand Down
63 changes: 39 additions & 24 deletions dashboard/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2046,7 +2046,9 @@ function updateModels(preserve = true) {
const provider = $("provider").value;
const previous = $("model").value;
const models = providerModels[provider] || [];
$("model").innerHTML = models.map((model) => `<option value="${model}">${model}</option>`).join("");
$("model").replaceChildren(
...models.map((model) => new Option(String(model), String(model))),
);
if (preserve && models.includes(previous)) $("model").value = previous;
$("activeProvider").textContent = provider;
$("activeModel").textContent = $("model").value || "-";
Expand Down Expand Up @@ -3576,7 +3578,10 @@ function normalizeTimeState(raw = {}) {
const bpm = Math.min(300, Math.max(20, Number(raw?.bpm) || defaults.bpm));
const bars = Math.min(128, Math.max(1, Math.round(Number(raw?.bars) || defaults.bars)));
const ppq = Math.min(3840, Math.max(24, Math.round(Number(raw?.ppq) || defaults.ppq)));
const sampleRate = Math.round(Number(raw?.sampleRate ?? raw?.sample_rate) || defaults.sampleRate);
// Backend time renders are fixed to SAME's 44.1 kHz clock. Session data
// created by older builds may contain another rate, so normalize it here
// before the clock reaches the API.
const sampleRate = 44100;
const snapDivision = ["1/4", "1/8", "1/16", "1/32", "triplet"].includes(raw?.snapDivision ?? raw?.snap_division)
? (raw.snapDivision ?? raw.snap_division)
: defaults.snapDivision;
Expand All @@ -3599,34 +3604,41 @@ function timeClockDerived(clock = timeState) {
const normalized = normalizeTimeState(clock);
const secondsPerBeat = 60 / normalized.bpm;
const totalBeats = normalized.bars * normalized.timeSignature.beatsPerBar;
const loopSeconds = totalBeats * secondsPerBeat;
const ticksPerBar = normalized.timeSignature.beatsPerBar * normalized.ppq;
const totalTicks = normalized.bars * ticksPerBar;
const loopStartTick = Math.min(totalTicks - 1, normalized.loopStartTick);
const requestedLoopEnd = Number(normalized.loopEndTick);
const loopEndTick = Number.isFinite(requestedLoopEnd) && requestedLoopEnd > loopStartTick
? Math.min(totalTicks, Math.round(requestedLoopEnd))
: totalTicks;
const loopSeconds = ((loopEndTick - loopStartTick) / normalized.ppq) * secondsPerBeat;
return {
secondsPerBeat,
totalBeats,
loopSeconds,
loopSamples: Math.round(loopSeconds * normalized.sampleRate),
ticksPerBar,
totalTicks,
loopStartTick: normalized.loopStartTick,
loopEndTick: normalized.loopEndTick || totalTicks,
loopStartTick,
loopEndTick,
};
}

function timeStateApiClock() {
const normalized = normalizeTimeState(timeState);
const derived = timeClockDerived(normalized);
return {
enabled: Boolean(timeState.enabled),
bpm: Number(timeState.bpm),
beats_per_bar: Number(timeState.timeSignature.beatsPerBar),
beat_unit: Number(timeState.timeSignature.beatUnit),
bars: Number(timeState.bars),
ppq: Number(timeState.ppq),
sample_rate: Number(timeState.sampleRate),
snap_division: timeState.snapDivision,
swing: Number(timeState.swing) || 0,
loop_start_tick: Number(timeState.loopStartTick) || 0,
loop_end_tick: timeState.loopEndTick || null,
enabled: Boolean(normalized.enabled),
bpm: normalized.bpm,
beats_per_bar: normalized.timeSignature.beatsPerBar,
beat_unit: normalized.timeSignature.beatUnit,
bars: normalized.bars,
ppq: normalized.ppq,
sample_rate: normalized.sampleRate,
snap_division: normalized.snapDivision,
swing: normalized.swing,
loop_start_tick: derived.loopStartTick,
loop_end_tick: derived.loopEndTick,
};
}

Expand Down Expand Up @@ -7963,19 +7975,20 @@ function renderSnapshotLibrary() {
const nodeCount = record.nodes?.length || 0;
const edgeCount = record.edges?.length || 0;
const date = record.createdAt ? new Date(record.createdAt).toLocaleString() : "—";
return `<div class="snapshot-card" data-snapshot-id="${record.id}">
<button class="snapshot-card-fav${isFav ? " is-fav" : ""}" data-action="snapshot-toggle-fav" data-snapshot-id="${record.id}" title="${isFav ? "Unfavorite" : "Favorite"}" type="button">
const safeRecordId = escapeHtml(record.id);
return `<div class="snapshot-card" data-snapshot-id="${safeRecordId}">
<button class="snapshot-card-fav${isFav ? " is-fav" : ""}" data-action="snapshot-toggle-fav" data-snapshot-id="${safeRecordId}" title="${isFav ? "Unfavorite" : "Favorite"}" type="button">
<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>
</button>
<div class="snapshot-card-info" data-action="snapshot-load" data-snapshot-id="${record.id}">
<div class="snapshot-card-info" data-action="snapshot-load" data-snapshot-id="${safeRecordId}">
<div class="snapshot-card-name">${escapeHtml(record.name || record.id)}</div>
<div class="snapshot-card-meta">${nodeCount} nodes · ${edgeCount} edges · ${date}</div>
<div class="snapshot-card-meta">${escapeHtml(nodeCount)} nodes · ${escapeHtml(edgeCount)} edges · ${escapeHtml(date)}</div>
</div>
<div class="snapshot-card-actions">
<button data-action="snapshot-rename" data-snapshot-id="${record.id}" title="Rename" type="button">
<button data-action="snapshot-rename" data-snapshot-id="${safeRecordId}" title="Rename" type="button">
<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>
</button>
<button class="snapshot-delete" data-action="snapshot-delete" data-snapshot-id="${record.id}" title="Delete" type="button">
<button class="snapshot-delete" data-action="snapshot-delete" data-snapshot-id="${safeRecordId}" title="Delete" type="button">
<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>
</button>
</div>
Expand Down Expand Up @@ -8076,7 +8089,7 @@ function renderSessionLibrary() {
return `<div class="snapshot-card" data-session-id="${escapeHtml(session.id)}">
<div class="snapshot-card-info" data-action="session-load" data-session-id="${escapeHtml(session.id)}">
<div class="snapshot-card-name">${escapeHtml(session.name || session.id)}</div>
<div class="snapshot-card-meta">${session.node_count} nodes · ${session.edge_count} edges · ${date}</div>
<div class="snapshot-card-meta">${escapeHtml(session.node_count)} nodes · ${escapeHtml(session.edge_count)} edges · ${escapeHtml(date)}</div>
</div>
<div class="snapshot-card-actions">
<button class="snapshot-delete" data-action="session-delete" data-session-id="${escapeHtml(session.id)}" title="Delete session" aria-label="Delete session" type="button">
Expand Down Expand Up @@ -16160,7 +16173,9 @@ async function canvasAcceptCandidate(candidateId, acceptAs) {
if (!candidate || !candidateAsset) return;
const sourceNode = canvasNodes.find((node) => node.id === candidate.sourceNodeId) || canvasSelectedNode();
if (acceptAs === "branch" || !sourceNode || sourceNode.type !== "sound") {
const sourceEl = sourceNode ? document.querySelector(`.canvas-node[data-node-id="${sourceNode.id}"]`) : null;
const sourceEl = sourceNode
? document.querySelector(`.canvas-node[data-node-id="${CSS.escape(sourceNode.id)}"]`)
: null;
const sourceH = sourceEl ? sourceEl.offsetHeight : (sourceNode?.height || 262);
let newX = (sourceNode?.x || 210) + 48;
let newY = (sourceNode?.y || 110) + 260;
Expand Down
Loading
Loading