# Project export: PathVoice

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2025
- Tagline: Let the World Speak to You
- Devpost: https://devpost.com/software/pathvoice-ai-tour-guide
- GitHub: https://github.com/ayushpai/pathvoice
- Video: https://www.youtube.com/embed/_47Cwv5oz7Y?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 2 GitHub contributor(s) — Ayush Pai (4 commits), Nikil Kandala (1 commits)

## Devpost submission (written by the team)

### Inspiration

Our inspiration came from a close friend we lovingly refer to as the “Serial Stalker.” He has a quirky habit of tracking our location on Apple’s Find My and giving us live, unsolicited virtual tours. When one of us landed in San Francisco for the first time, he immediately texted, “On your right, you’ll see the Roblox HQ!” followed by a rapid-fire stream of 10–20 more real-time facts and landmarks as we made our way through the Bay Area. That moment sparked the idea: what if we could build an AI-powered travel guide that uses your live location to narrate interesting facts and landmarks as you pass by, just like our friend.

### What it does

PathVoice is an AI-powered mobile tour guide that narrates your surroundings in real time based on your location. As you drive/walk, the app uses your GPS coordinates to pull nearby points of interest (like historical landmarks, museums, and hidden gems) using the Google Maps Places API. That information is then passed into an LLM (Gemini in our case), which generates an engaging spoken narration as if you're walking around the city with a personal guide!

### How we built it

CoreLocation (iOS Framework) We use CoreLocation to access real-time GPS coordinates directly from the user's device. This allows the app to continuously track the user’s movement with high accuracy while preserving battery life. It forms the foundation of the experience by triggering updates as the user approaches new landmarks. Google Maps Places API To discover nearby points of interest, we use the Google Maps Places API. Based on the user’s current coordinates and a defined radius, the API returns detailed information about attractions, landmarks, and venues in the surrounding area. This raw location data is then passed to the language model for contextualization. Gemini (gemini-2.0-flash) We use Gemini to transform structured place data into conversational narration. The model is prompted to describe nearby locations in a friendly, informative tone, tailored to the user’s movement. LMNT/Vapi API Once the narration is generated, it’s delivered using real-time text-to-speech through the LMNT/Vapi API. SwiftUI Used for the frontend and UI/UX (map view, narration controls, etc.)

### Challenges we ran into

One of the biggest issues we ran into was the delay from TTS as it can take 10 to 20 seconds to generate an MP3, which means the user could drive past a landmark before the audio is ready. We worked around this by pre-generating the narration and storing it until the user actually approaches the location. Another challenge was figuring out how to handle different use cases, like people walking vs. people driving. To fix that, we let users tweak how often we poll for nearby places (e.g. every 30 seconds for driving, every 2 minutes for walking), and they can also adjust the detection radius.

### Accomplishments we're proud of

We're definitely proud of building a functioning app and taking a 3-hour round trip to SF to test it out on a bus ride and while walking through the city all within the span of 20 hours. We're also proud of how quickly we were able to stitch together multiple APIs and frameworks to create a smooth, real-time experience.

### What we learned

We learned how to build an app that leverages real-time location data to deliver context-aware experiences. We figured out how to analyze GPS coordinates on the fly and connect them with meaningful nearby information. Most importantly, we learned how to turn that raw data into something genuinely useful and engaging for users.

### What's next

We want to make PathVoice smarter by using the phone’s speed to automatically adjust how often it checks for new landmarks. For example, if you're walking at 1 mph, it should poll every 5 minutes, but if you're driving at 70 mph, it should poll every 20 seconds. We also plan to use the phone’s heading and orientation to figure out what landmarks are in front of, behind, or to the side of the user. This would allow us to make the narration feel even more directional and immersive.

## README (from the GitHub repository)

# Inspiration

Our inspiration came from a close friend we lovingly refer to as the “Serial Stalker.” He has a quirky habit of tracking our location on Apple’s Find My and giving us live, unsolicited virtual tours. When one of us landed in San Francisco for the first time, he immediately texted, “On your right, you’ll see the Roblox HQ!” followed by a rapid-fire stream of 10–20 more real-time facts and landmarks as we made our way through the Bay Area. That moment sparked the idea: what if we could build an AI-powered travel guide that uses your live location to narrate interesting facts and landmarks as you pass by, just like our friend.

# What it does

PathVoice is an AI-powered mobile tour guide that narrates your surroundings in real time based on your location. As you drive/walk, the app uses your GPS coordinates to pull nearby points of interest (like historical landmarks, museums, and hidden gems) using the Google Maps Places API. That information is then passed into an LLM (Gemini in our case), which generates an engaging spoken narration as if you're walking around the city with a personal guide!

# How we built it

CoreLocation (iOS Framework) We use CoreLocation to access real-time GPS coordinates directly from the user's device. This allows the app to continuously track the user’s movement with high accuracy while preserving battery life. It forms the foundation of the experience by triggering updates as the user approaches new landmarks.

Google Maps Places API To discover nearby points of interest, we use the Google Maps Places API. Based on the user’s current coordinates and a defined radius, the API returns detailed information about attractions, landmarks, and venues in the surrounding area. This raw location data is then passed to the language model for contextualization.

Gemini (gemini-2.0-flash) We use Gemini to transform structured place data into conversational narration. The model is prompted to describe nearby locations in a friendly, informative tone, tailored to the user’s movement.

LMNT/Vapi API Once the narration is generated, it’s delivered using real-time text-to-speech through the LMNT/Vapi API.

SwiftUI Used for the frontend and UI/UX (map view, narration controls, etc.)

# Challenges we ran into

One of the biggest issues we ran into was the delay from TTS as it can take 10 to 20 seconds to generate an MP3, which means the user could drive past a landmark before the audio is ready. We worked around this by pre-generating the narration and storing it until the user actually approaches the location. Another challenge was figuring out how to handle different use cases, like people walking vs. people driving. To fix that, we let users tweak how often we poll for nearby places (e.g. every 30 seconds for driving, every 2 minutes for walking), and they can also adjust the detection radius.

# Accomplishments that we're proud of

We're definitely proud of building a functioning app and taking a 3-hour round trip to SF to test it out on a bus ride and while walking through the city all within the span of 20 hours. We're also proud of how quickly we were able to stitch together multiple APIs and frameworks to create a smooth, real-time experience.

# What we learned

We learned how to build an app that leverages real-time location data to deliver context-aware experiences. We figured out how to analyze GPS coordinates on the fly and connect them with meaningful nearby information. Most importantly, we learned how to turn that raw data into something genuinely useful and engaging for users.

# What's next for PathVoice

We want to make PathVoice smarter by using the phone’s speed to automatically adjust how often it checks for new landmarks. For example, if you're walking at 1 mph, it should poll every 5 minutes, but if you're driving at 70 mph, it should poll every 20 seconds. We also plan to use the phone’s heading and orientation to figure out what landmarks are in front of, behind, or to the side of the user. This would allow us to make the narration feel even more directional and immersive.

# Built With
- core-location
- gemini
- google-maps
- lmnt
- swiftui
- vapi


## Detected evidence (automated analysis)

Indexed codebase: 10 recognized source files, 59 KB.
- Swift (language) — detected in the code
- Google Gemini (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (18 of 18)

```
pathvoice.xcodeproj/project.pbxproj
pathvoice.xcodeproj/project.xcworkspace/contents.xcworkspacedata
pathvoice.xcodeproj/xcuserdata/ayushpai.xcuserdatad/xcschemes/xcschememanagement.plist
pathvoice/AIManager.swift
pathvoice/Assets.xcassets/AccentColor.colorset/Contents.json
pathvoice/Assets.xcassets/AppIcon.appiconset/Contents.json
pathvoice/Assets.xcassets/Contents.json
pathvoice/ContentView.swift
pathvoice/LocationManager.swift
pathvoice/pathvoice.entitlements
pathvoice/pathvoiceApp.swift
pathvoice/PlacesAPIService.swift
pathvoice/Preview Content/Preview Assets.xcassets/Contents.json
pathvoice/SpeechService.swift
pathvoiceTests/pathvoiceTests.swift
pathvoiceUITests/pathvoiceUITests.swift
pathvoiceUITests/pathvoiceUITestsLaunchTests.swift
README.md
```

### Dependencies

No dependency index available.

### Recent commits (newest first)

- Created README.md
- final commt
- added LLM integration
- initial test of geolocation api
- Initial Commit

## Key source files (fetched from GitHub, selected and truncated for size)

### pathvoice/pathvoiceApp.swift

```swift
//
//  pathvoiceApp.swift
//  pathvoice
//
//  Created by Ayush Pai on 6/21/25.
//

import SwiftUI

@main
struct pathvoiceApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

```

### pathvoiceTests/pathvoiceTests.swift

```swift
//
//  pathvoiceTests.swift
//  pathvoiceTests
//
//  Created by Ayush Pai on 6/21/25.
//

import Testing

struct pathvoiceTests {

    @Test func example() async throws {
        // Write your test here and use APIs like `#expect(...)` to check expected conditions.
    }

}

```

### pathvoiceUITests/pathvoiceUITestsLaunchTests.swift

```swift
//
//  pathvoiceUITestsLaunchTests.swift
//  pathvoiceUITests
//
//  Created by Ayush Pai on 6/21/25.
//

import XCTest

final class pathvoiceUITestsLaunchTests: XCTestCase {

    override class var runsForEachTargetApplicationUIConfiguration: Bool {
        true
    }

    override func setUpWithError() throws {
        continueAfterFailure = false
    }

    @MainActor
    func testLaunch() throws {
        let app = XCUIApplication()
        app.launch()

        // Insert steps here to perform after app launch but before taking a screenshot,
        // such as logging into a test account or navigating somewhere in the app

        let attachment = XCTAttachment(screenshot: app.screenshot())
        attachment.name = "Launch Screen"
        attachment.lifetime = .keepAlways
        add(attachment)
    }
}

```

### pathvoiceUITests/pathvoiceUITests.swift

```swift
//
//  pathvoiceUITests.swift
//  pathvoiceUITests
//
//  Created by Ayush Pai on 6/21/25.
//

import XCTest

final class pathvoiceUITests: XCTestCase {

    override func setUpWithError() throws {
        // Put setup code here. This method is called before the invocation of each test method in the class.

        // In UI tests it is usually best to stop immediately when a failure occurs.
        continueAfterFailure = false

        // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
    }

    override func tearDownWithError() throws {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }

    @MainActor
    func testExample() throws {
        // UI tests must launch the application that they test.
        let app = XCUIApplication()
        app.launch()

        // Use XCTAssert and related functions to verify your tests produce the correct results.
    }

    @MainActor
    func testLaunchPerformance() throws {
        if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) {
            // This measures how long it takes to launch your application.
            measure(metrics: [XCTApplicationLaunchMetric()]) {
                XCUIApplication().launch()
            }
        }
    }
}

```

### pathvoice/LocationManager.swift

```swift
//
//  LocationManager.swift
//  pathvoice
//
//  Created by Ayush Pai on 6/21/25.
//

import CoreLocation

class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
    private let locationManager = CLLocationManager()
    private let geocoder = CLGeocoder()

    @Published var location: CLLocationCoordinate2D?
    @Published var locationName: String = "Getting location..."

    override init() {
        super.init()
        locationManager.delegate = self
        locationManager.desiredAccuracy = kCLLocationAccuracyBest
        locationManager.requestWhenInUseAuthorization()
        locationManager.startUpdatingLocation()
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        location = locations.last?.coordinate
        
        // Reverse geocode to get location name
        if let location = locations.last {
            geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, error in
                DispatchQueue.main.async {
                    if let error = error {
                        print("Geocoding error: \(error)")
                        self?.locationName = "Unknown location"
                        return
                    }
                    
                    if let placemark = placemarks?.first {
                        var locationParts: [String] = []
                        
                        if let locality = placemark.locality {
                            locationParts.append(locality)
                        }
                        if let administrativeArea = placemark.administrativeArea {
                            locationParts.append(administrativeArea)
                        }
                        if let country = placemark.country {
                            locationParts.append(country)
                        }
                        
                        self?.locationName = locationParts.isEmpty ? "Unknown location" : locationParts.joined(separator: ", ")
                    } else {
                        self?.locationName = "Unknown location"
                    }
                }
            }
        }
    }
}

```

### pathvoice/PlacesAPIService.swift

```swift
//
//  PlacesAPIService.swift
//  pathvoice
//
//  Created by Ayush Pai on 6/21/25.
//

import Foundation
import CoreLocation

struct PlacesResponse: Codable {
    let places: [Place]
}

struct Place: Codable, Identifiable {
    let id = UUID()
    let displayName: DisplayName
    let formattedAddress: String
    let primaryType: String?
    let types: [String]
    let location: Location
    let photos: [PlacePhoto]?
    
    enum CodingKeys: String, CodingKey {
        case displayName
        case formattedAddress
        case primaryType
        case types
        case location
        case photos
    }
}

struct DisplayName: Codable {
    let text: String
    let languageCode: String
}

struct Location: Codable {
    let latitude: Double
    let longitude: Double
}

struct PlacePhoto: Codable {
    let name: String
    let widthPx: Int
    let heightPx: Int
    let authorAttributions: [AuthorAttribution]
}

struct AuthorAttribution: Codable {
    let displayName: String
    let uri: String
    let photoUri: String
}

class PlacesAPIService: ObservableObject {
    private let apiKey = ""
    private let baseURL = "https://places.googleapis.com/v1/places:searchNearby"
    
    @Published var places: [Place] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    func fetchNearbyTouristAttractions(coordinate: CLLocationCoordinate2D, radius: Double = 200.0) {
        isLoading = true
        errorMessage = nil
        
        let requestBody: [String: Any] = [
            "includedTypes": ["tourist_attraction"],
            "maxResultCount": 10,
            "locationRestriction": [
                "circle": [
                    "center": [
                        "latitude": coordinate.latitude,
                        "longitude": coordinate.longitude
                    ],
                    "radius": radius
                ]
            ]
        ]
        
        guard let url = URL(string: baseURL) else {
            errorMessage = "Invalid URL"
            isLoading = false
            return
        }
        
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue(apiKey, forHTTPHeaderField: "X-Goog-Api-Key")
        request.setValue("places.displayName,places.formattedAddress,places.primaryType,places.types,places.location,places.photos", forHTTPHeaderField: "X-Goog-FieldMask")
        
        do {
            request.httpBody = try JSONSerialization.data(withJSONObject: requestBody)
        } catch {
            errorMessage = "Failed to serialize request body: \(error.localizedDescription)"
            isLoading = false
            return
        }
        
        URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
            DispatchQueue.main.async {
                self?.isLoading = false
                
                if let error = error {
                    self?.errorMessage = "Network error: \(error.localizedDescription)"
                    return
                }
                
                guard let data = data else {
                    self?.errorMessage = "No data received"
                    return
                }
                
                do {
                    let placesResponse = try JSONDecoder().decode(PlacesResponse.self, from: data)
                    self?.places = placesResponse.places
                } catch {
                    self?.errorMessage = "Failed to decode response: \(error.localizedDescription)"
                    print("Response data: \(String(data: data, encoding: .utf8) ?? "Unable to decode")")
                }
            }
        }.resume()
    }

    func getPhotoURL(for photo: PlacePhoto, maxWidth: Int = 200) -> URL? {
        let baseURL = "https://places.googleapis.com/v1/\(photo.name)/media"
        var components = URLComponents(string: baseURL)
        components?.queryItems = [
            URLQueryItem(name: "maxWidthPx", value: "\(maxWidth)"),
            URLQueryItem(name: "key", value: apiKey)
        ]
        return components?.url
    }
} 

```

### pathvoice/AIManager.swift

```swift
//
//  AIManager.swift
//  pathvoice
//
//  Created by Ayush Pai on 6/21/25.
//

import Foundation

struct GeminiRequest: Codable {
    let contents: [GeminiContent]
}

struct GeminiContent: Codable {
    let parts: [GeminiPart]
}

struct GeminiPart: Codable {
    let text: String
}

struct GeminiResponse: Codable {
    let candidates: [GeminiCandidate]
}

struct GeminiCandidate: Codable {
    let content: GeminiContent
}

class AIManager: ObservableObject {
    private let apiKey = "" // Replace with your actual Gemini API key
    private let baseURL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent"
    
    @Published var currentTourGuideText: String = ""
    @Published var isLoading: Bool = false
    @Published var errorMessage: String?
    
    private var timer: Timer?
    private var currentAttractionId: String?
    private var speechService: SpeechService?
    
    init() {
        startPeriodicUpdates()
    }
    
    deinit {
        stopPeriodicUpdates()
    }
    
    func setSpeechService(_ speechService: SpeechService) {
        self.speechService = speechService
    }
    
    func startPeriodicUpdates() {
        // Update every 1 minute (60 seconds)
        timer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { [weak self] _ in
            self?.generateTourGuideContent()
        }
    }
    
    func stopPeriodicUpdates() {
        timer?.invalidate()
        timer = nil
    }
    
    func generateTourGuideContent() {
        // This will be called by the timer every minute
        // We'll need to get the current top attraction from the main app
        // For now, this will be called from ContentView when places are updated
    }
    
    func generateTourGuideForAttraction(_ attraction: Place) {
        // Check if this is the same attraction as before
        let attractionId = "\(attraction.displayName.text)_\(attraction.formattedAddress)"
        if attractionId == currentAttractionId {
            // Same attraction, don't regenerate
            return
        }
        
        // Update current attraction ID
        currentAttractionId = attractionId
        
        isLoading = true
        errorMessage = nil
        
        let systemPrompt = """
        You are an enthusiastic and knowledgeable tour guide providing real-time commentary to someone driving through the area. 
        
        Your role is to:
        - Provide interesting, engaging commentary about the location
        - Give historical context and fun facts
        - Make the driver feel like they have a personal tour guide
        - Keep your response around 100 words
        - Be conversational and engaging
        - Mention what's to the left and right if relevant
        - Focus on the specific attraction they're near
        
        Current location information:
        - Name: \(attraction.displayName.text)
        - Address: \(attraction.formattedAddress)
        - Type: \(attraction.primaryType ?? "Tourist Attraction")
        - Categories: \(attraction.types.joined(separator: ", "))
        
        Provide an engaging tour guide commentary as if you're speaking to someone driving by this location right now.
        """
        
        let requestBody = GeminiRequest(
            contents: [
                GeminiContent(
                    parts: [
                        GeminiPart(text: systemPrompt)
                    ]
                )
            ]
        )
        
        guard let url = URL(string: "\(baseURL)?key=\(apiKey)") else {
            errorMessage = "Invalid URL"
            isLoading = false
            return
        }
        
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        
        do {
            request.httpBody = try JSONEncoder().encode(requestBody)
        } catch {
            errorMessage = "Failed to encode request: \(error.localizedDescription)"
            isLoading = false
            return
        }
        
        URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
            DispatchQueue.main.async {
                self?.isLoading = false
                
                if let error = error {
                    self?.errorMessage = "Network error: \(error.localizedDescription)"
                    return
                }
                
                guard let data = data else {
                    self?.errorMessage = "No data received"
                    return
                }
                
                do {
                    let geminiResponse = try JSONDecoder().decode(GeminiResponse.self, from: data)
                    if let firstCandidate = geminiResponse.candidates.first,
                       let firstPart = firstCandidate.content.parts.first {
                        self?.currentTourGuideText = firstPart.text
                        
                        // Automatically speak the tour guide text
                        self?.speechService?.speakText(firstPart.text)
                    } else {
                        self?.errorMessage = "No content received from Gemini"
                    }
                } catch {
                    self?.errorMessage = "Failed to decode response: \(error.localizedDescription)"
                    print("Response data: \(String(data: data, encoding: .utf8) ?? "Unable to decode")")
                }
            }
        }.resume()
    }
    
    func generateTourGuideForAttractions(_ attractions: [Place]) {
        guard let topAttraction = attractions.first else {
            errorMessage = "No attractions available"
            return
        }
        
        generateTourGuideForAttraction(topAttraction)
    }
    
    func autoGenerateForTopAttraction(_ attractions: [Place]) {
        guard let topAttraction = attractions.first else {
            return
  
[truncated — 80 more characters]
```

### pathvoice/SpeechService.swift

```swift
//
//  SpeechService.swift
//  pathvoice
//
//  Created by Ayush Pai on 6/21/25.
//

import Foundation
import AVFoundation

class SpeechService: NSObject, ObservableObject {
    private let apiKey = "" // Replace with your LMNT API key
    private let baseURL = "https://api.lmnt.com/v1/ai/speech/bytes"
    
    @Published var isSpeaking = false
    @Published var isAudioPlaying = false
    @Published var errorMessage: String?
    
    private var audioPlayer: AVAudioPlayer?
    private var cachedAudioData: Data?
    private var currentText: String = ""
    
    override init() {
        super.init()
    }
    
    func speakText(_ text: String, voice: String = "ava") {
        guard !text.isEmpty else { 
            print("🔇 SpeechService: Empty text provided, skipping")
            return 
        }
        
        print("🎤 SpeechService: Starting speakText with voice: \(voice)")
        print("📝 SpeechService: Text length: \(text.count) characters")
        
        // Check if we have cached audio for this exact text
        if text == currentText, let cachedData = cachedAudioData {
            print("💾 SpeechService: Using cached audio data (\(cachedData.count) bytes)")
            // Use cached audio
            playAudio(data: cachedData)
            return
        }
        
        print("🔄 SpeechService: No cache found, generating new audio")
        isSpeaking = true
        errorMessage = nil
        
        let requestBody: [String: Any] = [
            "voice": voice,
            "text": text,
            "model": "blizzard",
            "language": "auto",
            "format": "mp3",
            "sample_rate": 24000,
            "seed": 42,
            "top_p": 0.3,
            "temperature": 0.3
        ]
        
        print("📡 SpeechService: Request body: \(requestBody)")
        
        guard let url = URL(string: baseURL) else {
            print("❌ SpeechService: Invalid URL")
            errorMessage = "Invalid URL"
            isSpeaking = false
            return
        }
        
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
        
        print("🌐 SpeechService: Making request to: \(url)")
        print("🔑 SpeechService: Using API key: \(String(apiKey.prefix(10)))...")
        
        do {
            request.httpBody = try JSONSerialization.data(withJSONObject: requestBody)
            print("📦 SpeechService: Request body encoded successfully")
        } catch {
            print("❌ SpeechService: Failed to encode request: \(error)")
            errorMessage = "Failed to encode request: \(error.localizedDescription)"
            isSpeaking = false
            return
        }
        
        URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
            DispatchQueue.main.async {
                print("📥 SpeechService: Received response")
                
                if let error = error {
                    print("❌ SpeechService: Network error: \(error)")
                    self?.errorMessage = "Network error: \(error.localizedDescription)"
                    self?.isSpeaking = false
                    return
                }
                
                if let httpResponse = response as? HTTPURLResponse {
                    print("📊 SpeechService: HTTP Status: \(httpResponse.statusCode)")
                    print("📋 SpeechService: Response headers: \(httpResponse.allHeaderFields)")
                }
                
                guard let data = data else {
                    print("❌ SpeechService: No audio data received")
                    self?.errorMessage = "No audio data received"
                    self?.isSpeaking = false
                    return
                }
                
                print("✅ SpeechService: Received audio data (\(data.count) bytes)")
                
                // Cache the audio data and text
                self?.cachedAudioData = data
                self?.currentText = text
                print("💾 SpeechService: Audio data cached")
                
                // Play the audio
                self?.playAudio(data: data)
            }
        }.resume()
    }
    
    private func playAudio(data: Data) {
        print("🎵 SpeechService: Starting playAudio")
        
        do {
            // Configure audio session
            try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
            try AVAudioSession.sharedInstance().setActive(true)
            print("🔊 SpeechService: Audio session configured")
            
            // Create audio player
            audioPlayer = try AVAudioPlayer(data: data)
            audioPlayer?.delegate = self
            print("🎧 SpeechService: Audio player created")
            
            let success = audioPlayer?.play() ?? false
            print("▶️ SpeechService: Audio play started: \(success)")
            
            isSpeaking = true
            isAudioPlaying = true
            print("🎯 SpeechService: State updated - isSpeaking: \(isSpeaking), isAudioPlaying: \(isAudioPlaying)")
        } catch {
            print("❌ SpeechService: Failed to play audio: \(error)")
            errorMessage = "Failed to play audio: \(error.localizedDescription)"
            isSpeaking = false
            isAudioPlaying = false
        }
    }
    
    func stopSpeaking() {
        print("⏹️ SpeechService: stopSpeaking called")
        audioPlayer?.stop()
        isSpeaking = false
        isAudioPlaying = false
        print("🎯 SpeechService: State updated - isSpeaking: \(isSpeaking), isAudioPlaying: \(isAudioPlaying)")
    }
    
    func pauseSpeaking() {
        print("⏸️ SpeechService: pauseSpeaking called")
        audioPlayer?.pause()
        isSpeaking = false
        print("🎯 SpeechService
[truncated — 1510 more characters]
```

### pathvoice/ContentView.swift

```swift
//
//  ContentView.swift
//  pathvoice
//
//  Created by Ayush Pai on 6/21/25.
//

import SwiftUI
import CoreLocation
import MapKit

struct ContentView: View {
    @StateObject private var locationManager = LocationManager()
    @StateObject private var placesService = PlacesAPIService()
    @StateObject private var aiManager = AIManager()
    @StateObject private var speechService = SpeechService()
    @State private var autoUpdateTimer: Timer?
    @State private var currentTopAttractionId: String?
    @State private var isTourActive = false
    @State private var isLoadingContent = false
    @State private var showingSettings = false
    @State private var pollingInterval: Double = 60.0
    @State private var searchRadius: Double = 400.0

    var body: some View {
        NavigationView {
            if isTourActive {
                // Tour Active View
                ZStack {
                    ScrollView {
                        VStack(spacing: 20) {
                            // Current Location Display
                            VStack(spacing: 16) {
                                HStack {
                                    Image(systemName: "location.circle.fill")
                                        .foregroundColor(.blue)
                                        .font(.title2)
                                    
                                    Text("Current Location: \(locationManager.locationName)")
                                        .font(.headline)
                                        .fontWeight(.semibold)
                                    
                                    Spacer()
                                }
                                
                                // Live Map
                                if let location = locationManager.location {
                                    VStack(spacing: 8) {
                                        Map(position: .constant(.userLocation(fallback: .automatic))) {
                                            UserAnnotation()
                                            
                                            // Add attraction pin if available and audio is playing
                                            if let topAttraction = placesService.places.first, speechService.isAudioPlaying {
                                                Annotation(
                                                    topAttraction.displayName.text,
                                                    coordinate: CLLocationCoordinate2D(
                                                        latitude: topAttraction.location.latitude,
                                                        longitude: topAttraction.location.longitude
                                                    )
                                                ) {
                                                    VStack(spacing: 0) {
                                                        Image(systemName: "mappin.circle.fill")
                                                            .foregroundColor(.red)
                                                            .font(.title)
                                                            .background(Color.white)
                                                            .clipShape(Circle())
                                                        
                                                        Text(topAttraction.displayName.text)
                                                            .font(.caption)
                                                            .padding(.horizontal, 6)
                                                            .padding(.vertical, 2)
                                                            .background(Color.white)
                                                            .foregroundColor(.black)
                                                            .cornerRadius(8)
                                                            .shadow(radius: 2)
                                                    }
                                                }
                                            }
                                        }
                                        .mapStyle(.standard)
                                        .frame(width: UIScreen.main.bounds.width - 80, height: 200)
                                        .clipShape(RoundedRectangle(cornerRadius: 12))
                                        .overlay(
                                            RoundedRectangle(cornerRadius: 12)
                                                .stroke(Color.blue.opacity(0.2), lineWidth: 1)
                                        )
                                        
                                        // Coordinates Display
                                        HStack(spacing: 20) {
                                            HStack(spacing: 4) {
                                                Text("Lat")
                                                    .font(.caption2)
                                                    .foregroundColor(.secondary)
                                                    .textCase(.uppercase)
                                                    .tracking(0.5)
                                                
                                                Text("\(location.latitude, specifier: "%.4f")")
                                                    .font(.system(.caption, design: .monospaced))
                                                    .fontWeight(.medium)
                                                    .foregroundColor(.primary)
                                            }
                                            
                                            Divider()
                                                .frame(height: 12)
                          
[truncated — 27892 more characters]
```