Skip to content

Commit 7546c13

Browse files
committed
Adopt AKOÚŌ v0.6 + Earworm v0.2; align preset vocabulary; July polish wave
Listening contract: - AKOÚŌ contract bumped to v0.6 (0.6-oida.1): /remember command + remember preset, memory-lineage-listening in the harness mode set, /fiction grants declared speculation, /forensic keeps its suppression - outputs carry akouo_version, apparatus (hybrid MOSS+DSP substrate, perception sources, known blind spots, model ids), and listener blocks; claims carry source (dsp/model/context) and time_range anchors on events - new harness/akouo/manifest.py loads the upstream machine-readable contract (akouo.manifest.json + presets/presets.json); tests include a drift check so the harness fallback tables cannot silently diverge Preset vocabulary aligned with upstream presets/presets.json: - environment->field, speech->voice, memory->recall, with LEGACY_PRESET_ALIASES on the daemon, preset_aliases in the manifest, and the same mapping in the macOS shell so saved selections follow the rename; oída preset ids are now a tested strict subset of the upstream vocabulary Earworm v0.2 (akousma spec v1.1) in the bridge: - records carry a skimmable summary; listening entries wrapped in the v1.1 envelope with akouo.* entries pinned to akouo/v0.6; registration auto-links recurrences (same_source_as on matching content hash) Also includes the previously uncommitted July overhaul documented in CHANGELOG 0.2.0 (floating-listener box redesign, dashboard restyle, engine and capture polish) plus repo hygiene (.uv-tools ignored, caches cleaned). 130 tests + 5 subtests green; ruff clean; swift build clean.
1 parent 6714882 commit 7546c13

35 files changed

Lines changed: 1036 additions & 165 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
.DS_Store
22
.venv/
33
.uv-cache/
4+
.uv-tools/
45
__pycache__/
56
*.py[cod]
67
.pytest_cache/

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,31 @@
22

33
## 0.2.0 - Unreleased
44

5+
- **Preset ids aligned with AKOÚŌ v0.6's portable vocabulary**:
6+
`environment``field`, `speech``voice`, `memory``recall` (with a
7+
`LEGACY_PRESET_ALIASES` map so saved configs, sessions, and older clients
8+
keep resolving; the manifest exposes `preset_aliases`). oída's preset id set
9+
is now a strict subset of the upstream `presets/presets.json` vocabulary,
10+
enforced by a test.
11+
- **AKOÚŌ v0.6 contract adopted** (`akouo_contract_version: v0.6`): new
12+
`/remember` command with a `remember` route preset (memory comparison plus
13+
registration into the akousmata) alongside the read-only `memory` preset;
14+
`memory-lineage-listening` added to the harness mode set; `/fiction` now
15+
grants declared speculative permission and `/forensic` keeps its
16+
interpreted/speculative suppression, both matching the published
17+
`command_permission_overrides`. Listening outputs now carry `akouo_version`,
18+
an `apparatus` declaration (hybrid MOSS+DSP substrate, perception sources,
19+
known blind spots, model ids), and a `listener` block; claims carry `source`
20+
(`dsp` / `model` / `context`) and, for events, `time_range` anchors. A new
21+
`harness/akouo/manifest.py` loads the upstream machine-readable contract
22+
(`akouo.manifest.json` + `presets/presets.json`) and the test suite includes
23+
a drift check so the harness fallback tables cannot silently diverge.
24+
- **Earworm v0.2 / akousma spec v1.1 adopted in the bridge**: listen records
25+
now carry a skimmable `summary`, listening entries are wrapped in the v1.1
26+
envelope (`contract`/`created_at`/`summary`/`payload`, with `akouo.*` entries
27+
pinned to `akouo/v0.6`), and registration links recurrences — when the same
28+
audio content hash already exists in the store, the new record gets a
29+
`same_source_as` relation to the most recent holder.
530
- **Floating listener redesigned**: the macOS floating listener is no longer a
631
window. It is a borderless, transparent, non-activating panel whose visible
732
body is the listening-result box itself — the reading is always shown, with a

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,13 @@ name **oída**.
3232
stereo correlation/width/balance. One inspection per file per listen (results
3333
are memoized on path+mtime).
3434
- **Route presets** scope the MOSS passes: Basic (one caption pass), Signal
35-
(DSP-only, instant), Environment, Music, Speech, Memory, and Deep (the full
36-
report). Presets come from the AKOÚŌ skill registry (`/akouo/skills`) and the
37-
dashboard skill manager can deviate per listen.
35+
(DSP-only, instant), Field, Music, Voice, Recall (read-only memory
36+
comparison), Remember (memory comparison + registration into the shared
37+
akousmata, AKOÚŌ `/remember`), and Deep (the full report). Preset ids follow
38+
AKOÚŌ v0.6's portable preset vocabulary (pre-v0.6 ids `environment`/`speech`/
39+
`memory` still resolve as aliases). Presets come from the AKOÚŌ skill
40+
registry (`/akouo/skills`) and the dashboard skill manager can deviate per
41+
listen.
3842
- **Akousmata memory** (private traces): remember/list/search/similar/export/
3943
forget over local JSON traces with deterministic DSP similarity. This is
4044
oída's private store — distinct from the shared akousmata below.

apps/macos/Sources/OidaMacOS/Models/DaemonModels.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,20 @@ struct AkouoSkillsResponse: Codable {
8585
struct ListenEventPayload: Codable {
8686
let path: String
8787
let routePreset: String
88+
let sourceType: String?
89+
let sourceLabel: String?
90+
let deviceId: String?
91+
let privacyMode: String?
92+
let rawAudioPolicy: String?
8893

8994
enum CodingKeys: String, CodingKey {
9095
case path
9196
case routePreset = "route_preset"
97+
case sourceType = "source_type"
98+
case sourceLabel = "source_label"
99+
case deviceId = "device_id"
100+
case privacyMode = "privacy_mode"
101+
case rawAudioPolicy = "raw_audio_policy"
92102
}
93103
}
94104

@@ -417,6 +427,7 @@ struct ConversationAskResponse: Codable {
417427
let conversationId: String?
418428
let eventId: String?
419429
let rawAudioPolicy: String?
430+
let persistent: Bool?
420431
let forbiddenTopicsTriggered: [String]?
421432
let turn: ConversationTurn?
422433

@@ -426,6 +437,7 @@ struct ConversationAskResponse: Codable {
426437
case conversationId = "conversation_id"
427438
case eventId = "event_id"
428439
case rawAudioPolicy = "raw_audio_policy"
440+
case persistent
429441
case forbiddenTopicsTriggered = "forbidden_topics_triggered"
430442
case turn
431443
}
@@ -492,6 +504,7 @@ struct GenerationRecord: Codable, Identifiable {
492504
let id: String
493505
let createdAt: String?
494506
let updatedAt: String?
507+
let persistent: Bool?
495508
let sourceEventId: String?
496509
let status: String?
497510
let adapter: String?
@@ -506,6 +519,7 @@ struct GenerationRecord: Codable, Identifiable {
506519
case id
507520
case createdAt = "created_at"
508521
case updatedAt = "updated_at"
522+
case persistent
509523
case sourceEventId = "source_event_id"
510524
case status
511525
case adapter
@@ -1004,7 +1018,7 @@ struct NativeSystemAudioAnalyzePayload: Codable {
10041018
}
10051019
}
10061020

1007-
struct NativeSystemAudioRoutePayload: Codable {
1021+
struct NativeSystemAudioRoutePayload: Codable, Sendable {
10081022
let routeId: String
10091023
let captureScope: String
10101024
let adapter: String

apps/macos/Sources/OidaMacOS/Services/DaemonClient.swift

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,25 @@ struct DaemonClient {
154154
try await post("/background/capture-request/claim", payload: CaptureRequestClaimPayload(id: id))
155155
}
156156

157-
func listenEvent(path: String, routePreset: String) async throws -> ListenEventResponse {
158-
try await post("/listen-event", payload: ListenEventPayload(path: path, routePreset: routePreset))
157+
func listenEvent(
158+
path: String,
159+
routePreset: String,
160+
sourceType: String? = nil,
161+
sourceLabel: String? = nil,
162+
deviceId: String? = nil,
163+
privacyMode: String? = nil,
164+
rawAudioPolicy: String? = nil
165+
) async throws -> ListenEventResponse {
166+
let payload = ListenEventPayload(
167+
path: path,
168+
routePreset: routePreset,
169+
sourceType: sourceType,
170+
sourceLabel: sourceLabel,
171+
deviceId: deviceId,
172+
privacyMode: privacyMode,
173+
rawAudioPolicy: rawAudioPolicy
174+
)
175+
return try await post("/listen-event", payload: payload)
159176
}
160177

161178
func akouoSkills() async throws -> AkouoSkillsResponse {

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

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ enum DaemonSupervisorError: LocalizedError {
1717
}
1818
}
1919

20+
@MainActor
2021
final class DaemonSupervisor {
2122
private var process: Process?
2223
private var outputPipe: Pipe?
@@ -58,14 +59,21 @@ final class DaemonSupervisor {
5859
proc.environment = mergedEnvironment()
5960

6061
outputPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
61-
self?.emitLines(from: handle.availableData)
62+
let data = handle.availableData
63+
Task { @MainActor [weak self] in
64+
self?.emitLines(from: data)
65+
}
6266
}
6367
errorPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
64-
self?.emitLines(from: handle.availableData)
68+
let data = handle.availableData
69+
Task { @MainActor [weak self] in
70+
self?.emitLines(from: data)
71+
}
6572
}
6673
proc.terminationHandler = { [weak self] process in
67-
DispatchQueue.main.async {
68-
self?.onExit?(process.terminationStatus)
74+
let status = process.terminationStatus
75+
Task { @MainActor [weak self] in
76+
self?.onExit?(status)
6977
self?.cleanup()
7078
}
7179
}
@@ -105,10 +113,8 @@ final class DaemonSupervisor {
105113
.map(String.init)
106114
.filter { !$0.isEmpty }
107115
guard !lines.isEmpty else { return }
108-
DispatchQueue.main.async { [weak self] in
109-
for line in lines {
110-
self?.onLogLine?(line)
111-
}
116+
for line in lines {
117+
onLogLine?(line)
112118
}
113119
}
114120

apps/macos/Sources/OidaMacOS/Services/MicTapManager.swift

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ enum MicTapError: LocalizedError {
1919

2020
/// Ring-buffered microphone capture via AVAudioEngine, mirroring the system
2121
/// tap so the floating listener can listen to the default input natively.
22-
final class MicTapManager {
23-
var onLevel: ((Double) -> Void)?
22+
final class MicTapManager: @unchecked Sendable {
23+
var onLevel: (@Sendable (Double) -> Void)?
2424

2525
private let engine = AVAudioEngine()
2626
private let queue = DispatchQueue(label: "org.sonicfield.oida.mic-tap")
@@ -42,7 +42,14 @@ final class MicTapManager {
4242
self?.ingest(buffer)
4343
}
4444
engine.prepare()
45-
try engine.start()
45+
do {
46+
try engine.start()
47+
} catch {
48+
input.removeTap(onBus: 0)
49+
engine.stop()
50+
queue.sync { ringSamples.removeAll(keepingCapacity: true) }
51+
throw error
52+
}
4653
isCapturing = true
4754
}
4855

@@ -104,9 +111,10 @@ final class MicTapManager {
104111
for value in mono { energy += Double(value * value) }
105112
let rms = (energy / Double(frames)).squareRoot()
106113
onLevel?(min(1.0, rms * 3.2))
114+
let capturedMono = mono
107115
queue.async { [weak self] in
108116
guard let self else { return }
109-
self.ringSamples.append(contentsOf: mono)
117+
self.ringSamples.append(contentsOf: capturedMono)
110118
let maxFrames = max(1, Int(round(self.ringSampleRate * self.ringMaxSeconds)))
111119
if self.ringSamples.count > maxFrames {
112120
self.ringSamples.removeFirst(self.ringSamples.count - maxFrames)

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,12 @@ final class ShellStore: ObservableObject {
287287
let loaded = (manifest.routePresets ?? []).filter { $0.enabledByDefault != false }
288288
if !loaded.isEmpty {
289289
presets = loaded
290+
// Pre-v0.6 preset ids saved in UserDefaults follow the rename instead of
291+
// resetting to the first preset (mirrors the daemon's LEGACY_PRESET_ALIASES).
292+
let legacyAliases = ["environment": "field", "speech": "voice", "memory": "recall"]
293+
if let renamed = legacyAliases[selectedPreset], loaded.contains(where: { $0.id == renamed }) {
294+
selectedPreset = renamed
295+
}
290296
if !loaded.contains(where: { $0.id == selectedPreset }) {
291297
selectedPreset = loaded.first?.id ?? "basic"
292298
}
@@ -346,7 +352,12 @@ final class ShellStore: ObservableObject {
346352
claimedCaptureRequestIds.removeAll()
347353
claimedCaptureRequestIds.insert(request.id)
348354
}
349-
guard let claim = try? await client.claimCaptureRequest(id: request.id), claim.claimed else { return }
355+
guard let claim = try? await client.claimCaptureRequest(id: request.id), claim.claimed else {
356+
// A transient daemon/network error must not poison this request ID;
357+
// the next poll should be allowed to retry the atomic claim.
358+
claimedCaptureRequestIds.remove(request.id)
359+
return
360+
}
350361
let seconds = request.seconds
351362
let preset = request.routePreset
352363
Task { @MainActor [weak self] in
@@ -417,7 +428,14 @@ final class ShellStore: ObservableObject {
417428
guard !Task.isCancelled else { return }
418429
do {
419430
let output = try micTap.writeRecentAudio(seconds: captureSeconds)
420-
let response = try await client.listenEvent(path: output.path, routePreset: preset ?? defaultRoutePreset)
431+
let response = try await client.listenEvent(
432+
path: output.path,
433+
routePreset: preset ?? defaultRoutePreset,
434+
sourceType: "live_input",
435+
sourceLabel: "Native microphone",
436+
privacyMode: "ephemeral",
437+
rawAudioPolicy: "temp"
438+
)
421439
latestRouteComparison = nil
422440
latestEvent = response.listeningEvent ?? latestEvent
423441
resetConversation()

apps/macos/Sources/OidaMacOS/Services/SystemAudioTapManager.swift

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import CoreMedia
33
import Foundation
44
import ScreenCaptureKit
55

6-
struct NativeAudioTapSnapshot {
6+
struct NativeAudioTapSnapshot: Sendable {
77
let bands: [Double]
88
let rms: Double
99
let peak: Double
@@ -12,7 +12,7 @@ struct NativeAudioTapSnapshot {
1212
let updatedAt: Date
1313
}
1414

15-
struct NativeSystemAudioCapture {
15+
struct NativeSystemAudioCapture: Sendable {
1616
let path: URL
1717
let durationSeconds: Double
1818
let sampleRate: Double
@@ -66,10 +66,10 @@ enum SystemAudioTapState: Equatable {
6666
}
6767
}
6868

69-
final class SystemAudioTapManager: NSObject {
70-
var onSnapshot: ((NativeAudioTapSnapshot) -> Void)?
71-
var onStateChange: ((SystemAudioTapState) -> Void)?
72-
var onRouteChange: ((NativeSystemAudioRoutePayload?) -> Void)?
69+
final class SystemAudioTapManager: NSObject, @unchecked Sendable {
70+
var onSnapshot: (@MainActor @Sendable (NativeAudioTapSnapshot) -> Void)?
71+
var onStateChange: (@MainActor @Sendable (SystemAudioTapState) -> Void)?
72+
var onRouteChange: (@MainActor @Sendable (NativeSystemAudioRoutePayload?) -> Void)?
7373

7474
private var stream: SCStream?
7575
private let captureQueue = DispatchQueue(label: "org.sonicfield.oida.system-audio-tap")
@@ -123,6 +123,7 @@ final class SystemAudioTapManager: NSObject {
123123
onStateChange?(.stopped)
124124
}
125125

126+
@MainActor
126127
func writeRecentAudio(seconds: Double, audioDirectory: URL = oidaAudioDirectory()) throws -> NativeSystemAudioCapture {
127128
guard isCapturing else { throw SystemAudioTapCaptureError.notCapturing }
128129
let sourceRoute = currentSourceRoute ?? fallbackDisplayMixRoute()
@@ -154,6 +155,7 @@ final class SystemAudioTapManager: NSObject {
154155
}
155156

156157
@available(macOS 13.0, *)
158+
@MainActor
157159
private func startScreenCaptureKit() async throws {
158160
let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
159161
guard let display = content.displays.first else {
@@ -256,7 +258,7 @@ final class SystemAudioTapManager: NSObject {
256258
updatedAt: Date()
257259
)
258260

259-
DispatchQueue.main.async { [weak self] in
261+
Task { @MainActor [weak self] in
260262
self?.onSnapshot?(snapshot)
261263
}
262264
}
@@ -409,7 +411,7 @@ extension SystemAudioTapManager: SCStreamOutput, SCStreamDelegate {
409411
}
410412

411413
func stream(_ stream: SCStream, didStopWithError error: Error) {
412-
DispatchQueue.main.async { [weak self] in
414+
Task { @MainActor [weak self] in
413415
self?.isCapturing = false
414416
self?.stream = nil
415417
self?.onStateChange?(.failed(error.localizedDescription))

apps/macos/Sources/OidaMacOS/Views/ContentView.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,11 @@ struct DashboardWebView: NSViewRepresentable {
140140
requestMediaCapturePermissionFor origin: WKSecurityOrigin,
141141
initiatedByFrame frame: WKFrameInfo,
142142
type: WKMediaCaptureType,
143-
decisionHandler: @escaping (WKPermissionDecision) -> Void
143+
decisionHandler: @escaping @MainActor @Sendable (WKPermissionDecision) -> Void
144144
) {
145-
decisionHandler(type == .microphone ? .grant : .deny)
145+
let localHosts = Set(["127.0.0.1", "localhost", "::1"])
146+
let isLocalDashboard = localHosts.contains(origin.host.lowercased())
147+
decisionHandler(type == .microphone && isLocalDashboard ? .grant : .deny)
146148
}
147149

148150
// target=_blank links (API docs, health) open in the default browser

0 commit comments

Comments
 (0)