Skip to content
Draft
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
84 changes: 84 additions & 0 deletions SoundCase.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

48 changes: 48 additions & 0 deletions SoundCase/Audio/CircularAudioBuffer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Foundation

/// Fixed-capacity ring buffer of Float32 audio frames (mono).
/// Writes that exceed capacity overwrite the oldest frames.
/// `snapshot()` returns frames in chronological order, oldest first.
final class CircularAudioBuffer {
let capacityFrames: Int
let sampleRate: Double
private var storage: [Float]
private var head: Int = 0
private var filled: Int = 0

init(sampleRate: Double, seconds: Double) {
self.sampleRate = sampleRate
self.capacityFrames = Int((sampleRate * seconds).rounded())
self.storage = Array(repeating: 0, count: max(1, capacityFrames))
}

func write(_ frames: UnsafePointer<Float>, frameCount: Int) {
guard frameCount > 0 else { return }
var srcIndex = 0
var remaining = frameCount
if remaining > capacityFrames {
srcIndex = remaining - capacityFrames
remaining = capacityFrames
}
for i in 0..<remaining {
storage[head] = frames[srcIndex + i]
head = (head + 1) % capacityFrames
}
filled = min(capacityFrames, filled + remaining)
}

func snapshot() -> [Float] {
guard filled > 0 else { return [] }
if filled < capacityFrames {
return Array(storage[0..<filled])
}
let tail = Array(storage[head..<capacityFrames])
let front = Array(storage[0..<head])
return tail + front
}

func clear() {
head = 0
filled = 0
}
}
55 changes: 55 additions & 0 deletions SoundCase/Audio/MonitorAudioWriter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import AVFoundation
import Foundation

final class MonitorAudioWriter {
private var audioFile: AVAudioFile?
private let format: AVAudioFormat
private(set) var framesWritten: AVAudioFramePosition = 0

init(url: URL, sampleRate: Double, channels: Int) throws {
guard let format = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: sampleRate,
channels: AVAudioChannelCount(channels),
interleaved: false
) else {
throw NSError(domain: "MonitorAudioWriter", code: 1)
}
let settings: [String: Any] = [
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
AVSampleRateKey: sampleRate,
AVNumberOfChannelsKey: channels,
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
]
self.audioFile = try AVAudioFile(forWriting: url, settings: settings, commonFormat: .pcmFormatFloat32, interleaved: false)
self.format = format
}

func writeFramesFromBuffer(_ buffer: CircularAudioBuffer) throws {
let frames = buffer.snapshot()
guard !frames.isEmpty else { return }
try frames.withUnsafeBufferPointer { ptr in
try writeFrames(ptr.baseAddress!, frameCount: frames.count)
}
}

func writeFrames(_ frames: UnsafePointer<Float>, frameCount: Int) throws {
guard frameCount > 0, let audioFile else { return }
guard let pcmBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(frameCount)) else {
throw NSError(domain: "MonitorAudioWriter", code: 2)
}
pcmBuffer.frameLength = AVAudioFrameCount(frameCount)
if let channelData = pcmBuffer.floatChannelData?[0] {
for i in 0..<frameCount {
channelData[i] = frames[i]
}
}
try audioFile.write(from: pcmBuffer)
framesWritten += AVAudioFramePosition(frameCount)
}

func close() throws {
// Release file reference so AVAudioFile finalizes the M4A header on dealloc
audioFile = nil
}
}
11 changes: 9 additions & 2 deletions SoundCase/Crypto/EventSigner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,22 @@ enum EventSigner {
"appVersion": appVersion,
"publicKeyFingerprintSha256": fingerprint.hexString
]
return [
var dict: [String: Any] = [
"schemaVersion": 1,
"eventId": event.id.uuidString,
"capturedAt": iso.string(from: event.startTime),
"audio": audio,
"db": db,
"location": location,
"device": device,
"identity": ["displayName": displayName] as [String: Any]
"identity": ["displayName": displayName] as [String: Any],
"monitorTrigger": NSNull()
]
if let json = event.monitorTriggerJSON,
let data = json.data(using: .utf8),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
dict["monitorTrigger"] = parsed
}
return dict
}
}
76 changes: 76 additions & 0 deletions SoundCase/Crypto/MonitorSessionSigner.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import Foundation
import UIKit

enum MonitorSessionSigner {
static func sign(
session: MonitorSession,
signingKey: SigningKey,
displayName: String,
appVersion: String
) throws {
session.publicKeyFingerprintAtStart = signingKey.publicKeyFingerprint
let blob = try CanonicalJSON.encode(canonicalDict(
session: session, displayName: displayName,
appVersion: appVersion, fingerprint: signingKey.publicKeyFingerprint
))
let hash = Hasher.sha256(blob)
session.canonicalBlobHash = hash
session.signatureDer = try signingKey.sign(hash)
}

static func verify(
session: MonitorSession,
signingKey: SigningKey,
displayName: String,
appVersion: String
) throws -> Bool {
let blob = try CanonicalJSON.encode(canonicalDict(
session: session, displayName: displayName,
appVersion: appVersion, fingerprint: signingKey.publicKeyFingerprint
))
let hash = Hasher.sha256(blob)
guard hash == session.canonicalBlobHash else { return false }
return try signingKey.verify(signature: session.signatureDer, payload: hash)
}

private static func canonicalDict(
session: MonitorSession,
displayName: String,
appVersion: String,
fingerprint: Data
) -> [String: Any] {
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let thresholds: [String: Any] = [
"daytimeDb": session.daytimeThresholdDb,
"quietHoursDb": session.quietHoursThresholdDb,
"quietHoursStartMin": session.quietHoursStartMinutesFromMidnight,
"quietHoursEndMin": session.quietHoursEndMinutesFromMidnight
]
let buffers: [String: Any] = [
"preBufferSeconds": session.preBufferSeconds,
"tailBufferSeconds": session.tailBufferSeconds
]
let calibration: [String: Any] = [
"status": session.calibrationStatusAtStart,
"offsetDb": session.calibrationOffsetAtStart as Any? ?? NSNull()
]
let device: [String: Any] = [
"model": UIDevice.current.model,
"osVersion": UIDevice.current.systemVersion,
"appVersion": appVersion,
"publicKeyFingerprintSha256": fingerprint.hexString
]
return [
"schemaVersion": 1,
"sessionId": session.id.uuidString,
"startedAt": iso.string(from: session.startedAt),
"plannedEndAt": iso.string(from: session.plannedEndAt),
"thresholds": thresholds,
"buffers": buffers,
"calibration": calibration,
"device": device,
"identity": ["displayName": displayName] as [String: Any]
]
}
}
72 changes: 72 additions & 0 deletions SoundCase/Models/MonitorSession.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Foundation
import SwiftData

enum MonitorSessionEndReason: String, Codable {
case inProgress
case userStopped
case timer
case hitCap
case crashed
case interrupted
}

@Model
final class MonitorSession {
var id: UUID
var startedAt: Date
var endedAt: Date?
var plannedEndAt: Date
var endReasonRaw: String
var daytimeThresholdDb: Double
var quietHoursThresholdDb: Double
var quietHoursStartMinutesFromMidnight: Int
var quietHoursEndMinutesFromMidnight: Int
var preBufferSeconds: Double
var tailBufferSeconds: Double
var calibrationOffsetAtStart: Double?
var calibrationStatusAtStart: String
var publicKeyFingerprintAtStart: Data
var signatureDer: Data
var canonicalBlobHash: Data
var interruptionCount: Int
var totalInterruptionSeconds: Double

var endReason: MonitorSessionEndReason {
get { MonitorSessionEndReason(rawValue: endReasonRaw) ?? .inProgress }
set { endReasonRaw = newValue.rawValue }
}

init(
startedAt: Date,
plannedEndAt: Date,
daytimeThresholdDb: Double,
quietHoursThresholdDb: Double,
quietHoursStartMinutesFromMidnight: Int,
quietHoursEndMinutesFromMidnight: Int,
preBufferSeconds: Double,
tailBufferSeconds: Double,
calibrationOffsetAtStart: Double?,
calibrationStatusAtStart: String
) {
self.id = UUID()
self.startedAt = startedAt
self.endedAt = nil
self.plannedEndAt = plannedEndAt
self.endReasonRaw = MonitorSessionEndReason.inProgress.rawValue
self.daytimeThresholdDb = daytimeThresholdDb
self.quietHoursThresholdDb = quietHoursThresholdDb
self.quietHoursStartMinutesFromMidnight = quietHoursStartMinutesFromMidnight
self.quietHoursEndMinutesFromMidnight = quietHoursEndMinutesFromMidnight
self.preBufferSeconds = preBufferSeconds
self.tailBufferSeconds = tailBufferSeconds
self.calibrationOffsetAtStart = calibrationOffsetAtStart
self.calibrationStatusAtStart = calibrationStatusAtStart
self.publicKeyFingerprintAtStart = Data()
self.signatureDer = Data()
self.canonicalBlobHash = Data()
self.interruptionCount = 0
self.totalInterruptionSeconds = 0
}
}

extension MonitorSession: Identifiable {}
2 changes: 2 additions & 0 deletions SoundCase/Models/NoiseEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ final class NoiseEvent {
var calibrationOffsetAtCapture: Double?
var calibrationStatusAtCapture: String
var importedUnsigned: Bool
var monitorTriggerJSON: String?

var duration: TimeInterval { endTime.timeIntervalSince(startTime) }

Expand Down Expand Up @@ -66,6 +67,7 @@ final class NoiseEvent {
self.calibrationOffsetAtCapture = calibrationOffsetAtCapture
self.calibrationStatusAtCapture = calibrationStatusAtCapture
self.importedUnsigned = false
self.monitorTriggerJSON = nil
}

/// Convenience init for callers that don't yet thread calibration through.
Expand Down
94 changes: 94 additions & 0 deletions SoundCase/Monitor/MonitorStateMachine.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import Foundation

enum MonitorState: String, Equatable {
case idle
case arming
case triggered
case cooldown
}

struct MonitorDecision: Equatable {
enum Kind: Equatable {
case none
case startTrigger(sustainedMs: Int)
case tailExtended
case finishEvent
case armingCancelled
}
let kind: Kind
}

final class MonitorStateMachine {
let armingMs: Int
let tailSeconds: Double
let cooldownSeconds: Double

private(set) var state: MonitorState = .idle
private var armingStartedAt: Date?
private var lastAboveThresholdAt: Date?
private var cooldownStartedAt: Date?

init(armingMs: Int = 500, tailSeconds: Double = 20.0, cooldownSeconds: Double = 3.0) {
self.armingMs = armingMs
self.tailSeconds = tailSeconds
self.cooldownSeconds = cooldownSeconds
}

func ingest(dB: Double, threshold: Double, now: Date) -> MonitorDecision {
let above = dB >= threshold

switch state {
case .idle:
if above {
state = .arming
armingStartedAt = now
}
return MonitorDecision(kind: .none)

case .arming:
if !above {
state = .idle
armingStartedAt = nil
return MonitorDecision(kind: .armingCancelled)
}
let elapsedMs = Int((now.timeIntervalSince(armingStartedAt ?? now)) * 1000)
if elapsedMs >= armingMs {
state = .triggered
lastAboveThresholdAt = now
armingStartedAt = nil
return MonitorDecision(kind: .startTrigger(sustainedMs: elapsedMs))
}
return MonitorDecision(kind: .none)

case .triggered:
if above {
lastAboveThresholdAt = now
return MonitorDecision(kind: .tailExtended)
}
let since = now.timeIntervalSince(lastAboveThresholdAt ?? now)
if since >= tailSeconds {
state = .cooldown
cooldownStartedAt = now
lastAboveThresholdAt = nil
return MonitorDecision(kind: .finishEvent)
}
return MonitorDecision(kind: .none)

case .cooldown:
let since = now.timeIntervalSince(cooldownStartedAt ?? now)
if since >= cooldownSeconds {
state = .idle
cooldownStartedAt = nil
return ingest(dB: dB, threshold: threshold, now: now)
}
return MonitorDecision(kind: .none)
}
}

func reset() {
state = .idle
armingStartedAt = nil
lastAboveThresholdAt = nil
cooldownStartedAt = nil
}
}
Loading