Skip to content

Commit

Permalink
Firebase Integration and Confidence Filtering (#35)
Browse files Browse the repository at this point in the history
# *Firebase Integration and Confidence Filtering*

## ♻️ Current situation & Problem
Currently, the CoughSync app can detect coughs using sound analysis, but
these detections are not persisted to any backend service. This means
that cough data is lost when the app is closed, preventing long-term
tracking and analysis. Additionally, the current implementation accepts
all detected coughs regardless of confidence level, leading to potential
false positives. This PR implements the Firebase integration for cough
data storage, fixes dependency injection issues in the
CoughDetectionViewModel, and adds confidence filtering to ensure only
high-quality cough detections are recorded.

## ⚙️ Release Notes 
Key features:
- Cough detections are now automatically stored in Firebase via the
CoughSyncStandard
- Only moderate-confidence coughs (>50%) are recorded to reduce false
positives
- Cough model enhanced to include confidence score information
- Fixed dependency injection issues in view models to properly access
the -
- CoughSyncStandard
Example of Firebase integration and confidence filtering:
if displayName == "Coughs" && confidence > 0.5 { // Only record if
confidence > 50%
    let cough = Cough(timestamp: Date(), confidence: confidence)
    coughCollection.addCough(cough)
    
    // Store the cough in Firebase
    Task {
        await standard.add(cough: cough)
    }
}

## 📚 Documentation
This PR introduces Firebase integration for persistent storage of cough
detection data. When a cough is detected with sufficient confidence
(>50%), it is both stored locally in the CoughCollection and sent to
Firebase through the CoughSyncStandard interface. The Cough model has
been enhanced to include a confidence property that stores the machine
learning model's confidence level for each detection. This value ranges
from 0.0 to 1.0, with higher values indicating greater confidence. By
filtering coughs at the 0.5 threshold, we significantly reduce false
positives while maintaining detection sensitivity. The dependency
injection architecture has been improved to properly access the
CoughSyncStandard from the SwiftUI environment. This follows the Spezi
framework's recommended patterns for dependency injection, initializing
view models when their containing views appear to ensure environment
values are available. All code changes are documented with inline
comments explaining the purpose of each component and the rationale
behind design decisions, in accordance with the Spezi Documentation
Guide.

## ✅ Testing
Testing verified Firebase storage, confidence filtering effectiveness,
and dependency injection fixes. Manual testing was used due to audio
input simulation challenges.


## 📝 Code of Conduct & Contributing Guidelines 

By submitting creating this pull request, you agree to follow our [Code
of
Conduct](https://github.com/CS342/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/CS342/.github/blob/main/CONTRIBUTING.md):
- [x] I agree to follow the [Code of
Conduct](https://github.com/CS342/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/CS342/.github/blob/main/CONTRIBUTING.md).
  • Loading branch information
aajain2 authored Mar 6, 2025
1 parent 5238df6 commit f3cae1e
Show file tree
Hide file tree
Showing 6 changed files with 105 additions and 36 deletions.
8 changes: 6 additions & 2 deletions CoughSync/CoughDetection/CoughClass.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,15 @@

import Foundation

class Cough {
class Cough: Identifiable, Codable {
let timestamp: Date
let id: UUID
let confidence: Double

init(timestamp: Date = Date()) {
init(timestamp: Date = Date(), id: UUID = UUID(), confidence: Double = 0) {
self.timestamp = timestamp
self.id = id
self.confidence = confidence
}
}

Expand Down
19 changes: 17 additions & 2 deletions CoughSync/CoughDetection/CoughDetectionViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,17 @@ import Combine
import Foundation
import Observation
import SoundAnalysis
import Spezi
import SwiftUI

@Observable
@MainActor
class CoughDetectionViewModel {
@ObservationIgnored let coughAnalysisManager = CoughAnalysisManager.shared

// Environment property to access the standard
private let standard: CoughSyncStandard

@ObservationIgnored var lastTime: Double = 0

var detectionStarted = false
Expand All @@ -34,6 +39,11 @@ class CoughDetectionViewModel {
var identifiedSound: (identifier: String, confidence: String)?
private var detectionCancellable: AnyCancellable?

// Initialize with standard from environment
init(standard: CoughSyncStandard) {
self.standard = standard
}

private func formattedDetectionResult(_ result: SNClassificationResult) -> (identifier: String, confidence: String)? {
guard let classification = result.classifications.first else {
return nil }
Expand All @@ -50,9 +60,14 @@ class CoughDetectionViewModel {
let confidencePercentString = String(format: "%.2f%%", confidence * 100.0)
print("\(displayName): \(confidencePercentString) confidence.\n")

if displayName == "Coughs" {
let cough = Cough(timestamp: Date())
if displayName == "Coughs" && confidence > 0.5 {
let cough = Cough(timestamp: Date(), confidence: confidence)
coughCollection.addCough(cough)

// Store the cough in Firebase
Task {
await standard.add(cough: cough)
}
}

return (displayName, confidencePercentString)
Expand Down
19 changes: 19 additions & 0 deletions CoughSync/CoughSyncStandard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,23 @@ actor CoughSyncStandard: Standard,
await logger.error("Could not store consent form: \(error)")
}
}

func add(cough: Cough) async {
if FeatureFlags.disableFirebase {
logger.debug("Received new cough event: \(cough.timestamp)")
return
}

do {
try await configuration.userDocumentReference
.collection("CoughEvents") // Store all cough events in a /CoughEvents collection
.document(UUID().uuidString) // Generate a unique ID for each cough event
.setData([
"timestamp": cough.timestamp,
"confidence": cough.confidence
])
} catch {
logger.error("Could not store cough event: \(error)")
}
}
}
50 changes: 31 additions & 19 deletions CoughSync/CoughView/CoughModelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,52 +12,64 @@
// Created by Ethan Bell on 12/2/2025.
//

import Spezi
import SwiftUI

struct CoughModelView: View {
@State var viewModel = CoughDetectionViewModel()
@Environment(CoughSyncStandard.self) private var standard
@State private var viewModel: CoughDetectionViewModel?

var body: some View {
VStack {
Spacer()
detectionStatusView()
Spacer()
microphoneButton()
if let viewModel = viewModel {
Spacer()
detectionStatusView()
Spacer()
microphoneButton()
.padding()
} else {
// Show a loading indicator
ProgressView("Loading...")
}
}
.onAppear {
// Initialize viewModel here when environment is available
viewModel = CoughDetectionViewModel(standard: standard)
}
.padding()
}

private var microphoneImage: some View {
Image(systemName: viewModel.detectionStarted ? "stop.fill" : "mic.fill")
Image(systemName: viewModel?.detectionStarted == true ? "stop.fill" : "mic.fill")
.font(.system(size: 50))
.padding(30)
.background(viewModel.detectionStarted ? .gray.opacity(0.7) : .blue)
.background(viewModel?.detectionStarted == true ? .gray.opacity(0.7) : .blue)
.foregroundStyle(.white)
.clipShape(Circle())
.shadow(color: .gray, radius: 5)
.contentTransition(.symbolEffect(.replace))
.accessibilityLabel(viewModel.detectionStarted ? "Stop sound detection" : "Start sound detection")
.accessibilityLabel(viewModel?.detectionStarted == true ? "Stop sound detection" : "Start sound detection")
}

@ViewBuilder
private func detectionStatusView() -> some View {
if !viewModel.detectionStarted {
if viewModel?.detectionStarted == false {
VStack(spacing: 10) {
ContentUnavailableView(
"No Sound Detected",
systemImage: "waveform.badge.magnifyingglass",
description: Text("Tap the microphone to start detecting")
)
Text("Cough Count: \(viewModel.coughCount)")
Text("Cough Count: \(viewModel?.coughCount ?? 0)")
.font(.title2)
.foregroundColor(.secondary)
}
} else if let predictedSound = viewModel.identifiedSound {
} else if let predictedSound = viewModel?.identifiedSound {
VStack(spacing: 10) {
Text(predictedSound.0)
.font(.system(size: 26))
Text("Cough Count: \(viewModel.coughCount)")
Text("Coughs Today: \(viewModel.coughCollection.coughsToday())")
Text("Cough Difference: \(viewModel.coughCollection.coughDiffDay())")
Text("Cough Count: \(viewModel?.coughCount ?? 0)")
Text("Coughs Today: \(viewModel?.coughCollection.coughsToday() ?? 0)")
Text("Cough Difference: \(viewModel?.coughCollection.coughDiffDay() ?? 0)")
}
.multilineTextAlignment(.center)
.padding()
Expand All @@ -78,12 +90,12 @@ struct CoughModelView: View {

private func toggleListening() {
withAnimation {
viewModel.detectionStarted.toggle()
viewModel?.detectionStarted.toggle()
}
if viewModel.detectionStarted {
viewModel.startListening()
if viewModel?.detectionStarted == true {
viewModel?.startListening()
} else {
viewModel.stopListening()
viewModel?.stopListening()
}
}
}
42 changes: 29 additions & 13 deletions CoughSync/Dashboard/Dashboard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,29 @@ import SpeziSchedulerUI
import SpeziViews
import SwiftUI


struct Dashboard: View {
// MARK: - Instance Properties
@Environment(Account.self) private var account: Account?
@Environment(CoughSyncStandard.self) private var standard
@Binding var presentingAccount: Bool

@State private var viewModel = CoughDetectionViewModel()
@State private var viewModel: CoughDetectionViewModel?
@State private var previousCoughCount: Int = 0

// MARK: - Body
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
coughSummaryCard()
coughStats()
Divider()
if let viewModel = viewModel {
VStack(spacing: 20) {
coughSummaryCard()
coughStats()
Divider()
}
.padding()
} else {
// Show a loading indicator or placeholder
ProgressView("Loading...")
}
.padding()
}
.navigationTitle("Summary")
.toolbar {
Expand All @@ -45,14 +51,24 @@ struct Dashboard: View {
}
}
.onAppear {
previousCoughCount = viewModel.coughCount
// Initialize viewModel here when environment is available
viewModel = CoughDetectionViewModel(standard: standard)
}
.onAppear {
previousCoughCount = viewModel?.coughCount ?? 0
}
.onChange(of: viewModel.coughCount) { oldValue, _ in
previousCoughCount = oldValue
.onChange(of: viewModel?.coughCount) { oldValue, _ in
previousCoughCount = oldValue ?? 0
}
}
}

// MARK: - Initializers
init(presentingAccount: Binding<Bool>) {
self._presentingAccount = presentingAccount
}

// MARK: - Methods
@ViewBuilder
private func coughSummaryCard() -> some View {
VStack {
Expand All @@ -61,7 +77,7 @@ struct Dashboard: View {
Text("Today")
.font(.headline)
.foregroundColor(.primary)
Text("\(viewModel.coughCount) ")
Text("\(viewModel?.coughCount ?? 0) ")
.font(.largeTitle)
.bold()
.foregroundColor(.blue)
Expand Down Expand Up @@ -112,7 +128,7 @@ struct Dashboard: View {

@ViewBuilder
private func statusCircle() -> some View {
let change = viewModel.coughCount - previousCoughCount
let change = viewModel?.coughCount ?? 0 - previousCoughCount
let color: Color = change > 0 ? .red : (change < 0 ? .green : .blue)
let trendSymbol = change > 0 ? "" : (change < 0 ? "" : "")

Expand Down
3 changes: 3 additions & 0 deletions CoughSync/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,9 @@
}
}
}
},
"Loading..." : {

},
"Next" : {
"localizations" : {
Expand Down

0 comments on commit f3cae1e

Please sign in to comment.