diff --git a/.env.example b/.env.example index 25115d3..353eae6 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fb09d6..6b82f88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/CITATION.cff b/CITATION.cff index f07bd34..b3b681e 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -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." diff --git a/README.md b/README.md index 0c263db..9efdd11 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md index f3e53bb..b673694 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/apps/macos/Sources/GermMacOS/Services/DaemonSupervisor.swift b/apps/macos/Sources/GermMacOS/Services/DaemonSupervisor.swift index eb85eb0..037879e 100644 --- a/apps/macos/Sources/GermMacOS/Services/DaemonSupervisor.swift +++ b/apps/macos/Sources/GermMacOS/Services/DaemonSupervisor.swift @@ -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 diff --git a/apps/macos/Sources/GermMacOS/Services/ShellStore.swift b/apps/macos/Sources/GermMacOS/Services/ShellStore.swift index 1834708..4ef2dbe 100644 --- a/apps/macos/Sources/GermMacOS/Services/ShellStore.swift +++ b/apps/macos/Sources/GermMacOS/Services/ShellStore.swift @@ -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. @@ -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 { @@ -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 { @@ -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 { @@ -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() { diff --git a/apps/macos/script/build_and_run.sh b/apps/macos/script/build_and_run.sh index 12bd759..ffd3da7 100755 --- a/apps/macos/script/build_and_run.sh +++ b/apps/macos/script/build_and_run.sh @@ -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 diff --git a/clients/python_client.py b/clients/python_client.py index 703d1df..77fad69 100644 --- a/clients/python_client.py +++ b/clients/python_client.py @@ -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() @@ -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 diff --git a/dashboard/static/app.js b/dashboard/static/app.js index 8c408a0..c221a20 100644 --- a/dashboard/static/app.js +++ b/dashboard/static/app.js @@ -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) => `${model}`).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 || "-"; @@ -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; @@ -3599,9 +3604,14 @@ 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, @@ -3609,24 +3619,26 @@ function timeClockDerived(clock = timeState) { 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, }; } @@ -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 `