Skip to content
Merged
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
30 changes: 27 additions & 3 deletions .github/workflows/soundcompass.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,39 @@ jobs:
- name: Check out repository
uses: actions/checkout@v4

- name: Show Xcode version
run: xcodebuild -version
# The runner image's default Xcode can lag the simulators the tests
# need (an image update left the default at 16.4, with no iPhone 17
# simulator). Use the newest non-beta Xcode installed on the runner.
- name: Select newest Xcode
run: |
XCODE=$(ls -d /Applications/Xcode_*.app | grep -iv beta | sort -V | tail -1)
sudo xcode-select -s "$XCODE/Contents/Developer"
xcodebuild -version

- name: Install XcodeGen
run: brew install xcodegen

- name: Generate Xcode project
run: xcodegen generate

# Prefer an iPhone 17 Pro simulator, else the first available iPhone,
# so a runner image without that exact model still runs the tests.
- name: Pick iOS simulator
run: |
SIM_ID=$(xcrun simctl list devices available -j | python3 -c '
import json, sys
devs = [d for rt, ds in json.load(sys.stdin)["devices"].items()
if "iOS" in rt for d in ds if d["name"].startswith("iPhone")]
pref = [d for d in devs if d["name"] == "iPhone 17 Pro"]
print((pref or devs)[0]["udid"] if devs else "")
')
if [ -z "$SIM_ID" ]; then
echo "No iPhone simulator available" >&2
xcrun simctl list devices available
exit 1
fi
echo "SIM_ID=$SIM_ID" >> "$GITHUB_ENV"

# `xcodebuild test` builds the app, the widget extension and the
# test bundle, so a separate build step would only repeat the work.
- name: Build and run unit tests
Expand All @@ -36,7 +60,7 @@ jobs:
-project SoundCompass.xcodeproj \
-scheme SoundCompass-iOS \
-configuration Debug \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=latest' \
-destination "platform=iOS Simulator,id=$SIM_ID" \
CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO \
-resultBundlePath build/TestResults.xcresult | xcpretty

Expand Down
13 changes: 13 additions & 0 deletions Shared/DirectionLabel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ enum DirectionLabel {
}
}

/// Full spoken sentence for an unlabeled sound. Each bucket is its own
/// localized sentence so the centre reads "Sound ahead" rather than
/// interpolating "ahead" into "Sound from your …".
static func spokenSentence(direction: Double) -> String {
switch direction {
case ..<(-0.66): return String(localized: "Sound from your far left")
case ..<(-0.20): return String(localized: "Sound from your left")
case ..<(0.20): return String(localized: "Sound ahead")
case ..<(0.66): return String(localized: "Sound from your right")
default: return String(localized: "Sound from your far right")
}
}

/// Accessibility value string combining direction and loudness.
static func accessibilityValue(direction: Double, magnitude: Double) -> String {
let bucket = spokenPhrase(direction: direction)
Expand Down
3 changes: 1 addition & 2 deletions SoundCompass/Intents/SoundCompassIntents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,7 @@ struct AnnounceDirectionIntent: AppIntent {
guard let detector = IntentBroker.detector, detector.isRunning else {
return "SoundCompass is not listening right now."
}
let word = DirectionLabel.spokenPhrase(direction: detector.direction)
return "Sound from your \(word)."
return DirectionLabel.spokenSentence(direction: detector.direction) + "."
}
return .result(dialog: IntentDialog(stringLiteral: phrase))
}
Expand Down
16 changes: 10 additions & 6 deletions SoundCompass/Services/SpeechAnnouncer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ final class SpeechAnnouncer: NSObject {
private let directionHysteresis: Double = 0.3

private var lastAnnouncement: Date = .distantPast
private var lastDirection: Double = 0
// nil until the first announcement, so a first sound that is centred and
// unlabeled (matching the old 0 / nil defaults) is still announced.
private var lastDirection: Double?
private var lastLabel: String?

init(minInterval: TimeInterval = 2.0) {
Expand All @@ -62,9 +64,11 @@ final class SpeechAnnouncer: NSObject {
guard magnitude >= loudnessThreshold else { return }
guard now.timeIntervalSince(lastAnnouncement) > minInterval else { return }

let directionChanged = abs(direction - lastDirection) >= directionHysteresis
let labelChanged = label != lastLabel
guard directionChanged || labelChanged else { return }
if let lastDirection {
let directionChanged = abs(direction - lastDirection) >= directionHysteresis
let labelChanged = label != lastLabel
guard directionChanged || labelChanged else { return }
}

let phrase = buildPhrase(direction: direction, label: label)
onPhrase?(phrase)
Expand All @@ -90,11 +94,11 @@ final class SpeechAnnouncer: NSObject {
}

private func buildPhrase(direction: Double, label: String?) -> String {
let locationWord = DirectionLabel.spokenPhrase(direction: direction)
if let label, !label.isEmpty {
let locationWord = DirectionLabel.spokenPhrase(direction: direction)
return String(localized: "\(label.lowercased()) \(locationWord)")
} else {
return String(localized: "Sound from your \(locationWord)")
return DirectionLabel.spokenSentence(direction: direction)
}
}

Expand Down
6 changes: 5 additions & 1 deletion SoundCompass/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,11 @@
"right" = "right";
"far right" = "far right";
"%@ %@" = "%@ %@";
"Sound from your %@" = "Sound from your %@";
"Sound from your far left" = "Sound from your far left";
"Sound from your left" = "Sound from your left";
"Sound ahead" = "Sound ahead";
"Sound from your right" = "Sound from your right";
"Sound from your far right" = "Sound from your far right";
"Microphone access denied." = "Microphone access denied.";
"Could not start recording: %@" = "Could not start recording: %@";
"Could not start audio: %@" = "Could not start audio: %@";
Expand Down
6 changes: 5 additions & 1 deletion SoundCompass/es.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,11 @@
"right" = "a la derecha";
"far right" = "muy a la derecha";
"%@ %@" = "%@ %@";
"Sound from your %@" = "Sonido %@";
"Sound from your far left" = "Sonido muy a la izquierda";
"Sound from your left" = "Sonido a la izquierda";
"Sound ahead" = "Sonido delante";
"Sound from your right" = "Sonido a la derecha";
"Sound from your far right" = "Sonido muy a la derecha";

// MARK: - Controls

Expand Down
8 changes: 8 additions & 0 deletions SoundCompassTests/DirectionLabelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ final class DirectionLabelTests: XCTestCase {
XCTAssertEqual(DirectionLabel.spokenPhrase(direction: 0.9), "far right")
}

func testSpokenSentenceBuckets() {
XCTAssertEqual(DirectionLabel.spokenSentence(direction: -0.9), "Sound from your far left")
XCTAssertEqual(DirectionLabel.spokenSentence(direction: -0.3), "Sound from your left")
XCTAssertEqual(DirectionLabel.spokenSentence(direction: 0.0), "Sound ahead")
XCTAssertEqual(DirectionLabel.spokenSentence(direction: 0.4), "Sound from your right")
XCTAssertEqual(DirectionLabel.spokenSentence(direction: 0.9), "Sound from your far right")
}

func testAccessibilityValueMentionsBothDirectionAndLoudness() {
let value = DirectionLabel.accessibilityValue(direction: -0.8, magnitude: 0.5)
XCTAssertTrue(value.contains("far left"))
Expand Down
12 changes: 12 additions & 0 deletions SoundCompassTests/SpeechAnnouncerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@ final class SpeechAnnouncerTests: XCTestCase {
XCTAssertEqual(spoken(), ["Sound from your left"])
}

func testCenteredPhraseWithoutLabelIsGrammatical() {
let (announcer, spoken) = silentAnnouncer()
announcer.announce(direction: 0, magnitude: 0.9, label: nil)
XCTAssertEqual(spoken(), ["Sound ahead"])
}

func testFarRightPhraseWithoutLabel() {
let (announcer, spoken) = silentAnnouncer()
announcer.announce(direction: 0.9, magnitude: 0.9, label: "")
XCTAssertEqual(spoken(), ["Sound from your far right"])
}

func testSecondAnnouncementInsideMinIntervalIsDropped() {
let (announcer, spoken) = silentAnnouncer(minInterval: 2.0)
let t0 = Date(timeIntervalSince1970: 1_000)
Expand Down
Loading