diff --git a/ACKNOWLEDGEMENTS.md b/ACKNOWLEDGEMENTS.md
new file mode 100644
index 000000000..fcb543ae2
--- /dev/null
+++ b/ACKNOWLEDGEMENTS.md
@@ -0,0 +1,53 @@
+# Acknowledgements
+
+Third-party material bundled with Loop, and the terms it is used under.
+
+## Alarm and alert sounds
+
+The themed glucose alarm sets (`acoustic_*.caf`, `electronic_*.caf`) and the
+one-off tones (`glass_bell.caf`, `harp.caf`, `meow.caf`, `saw_wave.caf`,
+`hard_saw_wave.caf`, `sine_wave.caf`, `open_can.caf`, `open_wrapper.caf`) were
+created for the Trio project by **an anonymous diabetes dad and Trio user**.
+
+- Licence: **Creative Commons Zero 1.0 Universal (CC0 1.0)** —
+
+- The author states that they are the author of these files, that they hold
+ the right to license them, and that they contain no third-party material
+ they cannot license. The CC0 dedication is what allows them to be carried
+ here as well as in Trio.
+- CC0 requires no attribution. The credit above is given at the author's
+ request; they have asked to remain anonymous.
+- Changes made: the one-off tones were looped by whole repeats to at least
+ five seconds, so that every alarm sounds for a comparable time; the themed
+ sets were left as delivered, since their length and level carry the urgency
+ along with the timbre. All were then encoded to IMA4 `.caf`.
+
+The spoken alert sounds are generated from third-party voice models and are
+*not* covered by that dedication — see below.
+
+## Voice alerts
+
+The spoken alert sounds were generated with
+[Piper](https://github.com/rhasspy/piper) using voices from the
+[rhasspy/piper-voices](https://huggingface.co/rhasspy/piper-voices) collection.
+
+### Amy (`voice_*_amy.caf`)
+
+- Model: `en/en_US/amy/medium`
+- Source dataset: [MycroftAI/mimic3-voices](https://github.com/MycroftAI/mimic3-voices)
+- Licence: **Creative Commons Attribution-ShareAlike 4.0 International
+ (CC BY-SA 4.0)** —
+- Changes made: the generated phrases were looped to a minimum duration of
+ five seconds so a spoken alert lasts long enough to be heard as an alarm,
+ then encoded to IMA4 `.caf`. No other processing was applied.
+- These audio files are made available under CC BY-SA 4.0. This does not
+ change the licence of Loop itself.
+
+### Bryce (`voice_*_bryce.caf`)
+
+- Model: `en/en_US/bryce/medium`
+- Source: (training data released into the public
+ domain by the speaker)
+- Licence: **Public domain.** Credited here as a courtesy; no attribution is
+ required.
+- Changes made: looped and encoded as above.
diff --git a/LICENSE.md b/LICENSE.md
index 15256eda2..7e3f3d21b 100644
--- a/LICENSE.md
+++ b/LICENSE.md
@@ -1,5 +1,6 @@
# Loop is released under the MIT license (MIT)
-With exceptions for frameworks and graphics, noted below.
+With exceptions for frameworks and graphics, noted below, and for the bundled
+alarm sounds and voices, listed in [ACKNOWLEDGEMENTS.md](ACKNOWLEDGEMENTS.md).
Copyright (c) 2015 Nathan Racklyeft
Copyright (c) 2016 LoopKit Authors
diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj
index 009552875..30cd70d1e 100644
--- a/Loop.xcodeproj/project.pbxproj
+++ b/Loop.xcodeproj/project.pbxproj
@@ -10,6 +10,46 @@
01414522FC033F2B7A9B223A /* Loop/Resources/Sounds/crying.caf in Resources */ = {isa = PBXBuildFile; fileRef = 85203550670F2E9BE92F27D8 /* Loop/Resources/Sounds/crying.caf */; };
0A02E77AD7536C322933D49B /* Loop/Resources/Sounds/minimal.caf in Resources */ = {isa = PBXBuildFile; fileRef = 74145BE3C46270883D295A1F /* Loop/Resources/Sounds/minimal.caf */; };
124B300527D84BE2A1233A91 /* Loop/Resources/Sounds/urgent_low.caf in Resources */ = {isa = PBXBuildFile; fileRef = C9EA3F48338B49BD9C6BD0F9 /* Loop/Resources/Sounds/urgent_low.caf */; };
+ 7877942ED674BA37847514AD /* Loop/Resources/Sounds/acoustic_hg_harsh.caf in Resources */ = {isa = PBXBuildFile; fileRef = A9B1F564432BCBF70C0C6CBB /* Loop/Resources/Sounds/acoustic_hg_harsh.caf */; };
+ A82DB18B916DC17D2E180D59 /* Loop/Resources/Sounds/acoustic_hg_medium.caf in Resources */ = {isa = PBXBuildFile; fileRef = 3481DFC7974E27ABE6AAC4C3 /* Loop/Resources/Sounds/acoustic_hg_medium.caf */; };
+ 4A208427654F9A249EC2CFDB /* Loop/Resources/Sounds/acoustic_hg_mild.caf in Resources */ = {isa = PBXBuildFile; fileRef = 1DCC85EA42C8D22C1EC824D8 /* Loop/Resources/Sounds/acoustic_hg_mild.caf */; };
+ F44BED105495ABA988BAB05F /* Loop/Resources/Sounds/acoustic_lg_harsh.caf in Resources */ = {isa = PBXBuildFile; fileRef = A5E01C8397D139F93B60CA0A /* Loop/Resources/Sounds/acoustic_lg_harsh.caf */; };
+ B0007FE66FB0FE886FACD77D /* Loop/Resources/Sounds/acoustic_lg_medium.caf in Resources */ = {isa = PBXBuildFile; fileRef = D64AC8FAA2340810ECB85AE9 /* Loop/Resources/Sounds/acoustic_lg_medium.caf */; };
+ D0EBF80B1D85F96B156725AA /* Loop/Resources/Sounds/acoustic_lg_mild.caf in Resources */ = {isa = PBXBuildFile; fileRef = 65E7CB20BE6AB6A39AF072A2 /* Loop/Resources/Sounds/acoustic_lg_mild.caf */; };
+ 3461DE9DBC0B97D5662244FB /* Loop/Resources/Sounds/acoustic_lgs_harsh.caf in Resources */ = {isa = PBXBuildFile; fileRef = 688FE8312F66C4C1909FF93F /* Loop/Resources/Sounds/acoustic_lgs_harsh.caf */; };
+ F98A16085E9F216CEDDB18DB /* Loop/Resources/Sounds/acoustic_lgs_medium.caf in Resources */ = {isa = PBXBuildFile; fileRef = E5D3EF6AEBD5FFAF2ABB187B /* Loop/Resources/Sounds/acoustic_lgs_medium.caf */; };
+ 182579BE820905E2B9FCC1B4 /* Loop/Resources/Sounds/acoustic_lgs_mild.caf in Resources */ = {isa = PBXBuildFile; fileRef = 16BE482D6AF35127A36FD9EB /* Loop/Resources/Sounds/acoustic_lgs_mild.caf */; };
+ 6AB8817E7C134FE630492390 /* Loop/Resources/Sounds/acoustic_ulg_harsh.caf in Resources */ = {isa = PBXBuildFile; fileRef = A96BF0E0B93A88D6BCEC2338 /* Loop/Resources/Sounds/acoustic_ulg_harsh.caf */; };
+ BAD8333EC083569038F10E88 /* Loop/Resources/Sounds/acoustic_ulg_medium.caf in Resources */ = {isa = PBXBuildFile; fileRef = CC3E2E706E45342C823D641F /* Loop/Resources/Sounds/acoustic_ulg_medium.caf */; };
+ 6E0CBD04FE69B38497EFC6F0 /* Loop/Resources/Sounds/acoustic_ulg_mild.caf in Resources */ = {isa = PBXBuildFile; fileRef = E38835D52BBD5607D90B9615 /* Loop/Resources/Sounds/acoustic_ulg_mild.caf */; };
+ DB1B4A63BB3DE5DF05D56791 /* Loop/Resources/Sounds/electronic_hg_harsh.caf in Resources */ = {isa = PBXBuildFile; fileRef = 2D388DFB60E5960C857807D0 /* Loop/Resources/Sounds/electronic_hg_harsh.caf */; };
+ 0EA4FC2C9D24030B0D586F80 /* Loop/Resources/Sounds/electronic_hg_medium.caf in Resources */ = {isa = PBXBuildFile; fileRef = 987F0560C0D00A3F1AC4D006 /* Loop/Resources/Sounds/electronic_hg_medium.caf */; };
+ 09789816D7760CF9BAA96F28 /* Loop/Resources/Sounds/electronic_hg_mild.caf in Resources */ = {isa = PBXBuildFile; fileRef = C8BD9DF4B53E440810325EA6 /* Loop/Resources/Sounds/electronic_hg_mild.caf */; };
+ D1530992424BA31F0A44EA27 /* Loop/Resources/Sounds/electronic_lg_harsh.caf in Resources */ = {isa = PBXBuildFile; fileRef = FE620B9DFF719ED07CEEA1B4 /* Loop/Resources/Sounds/electronic_lg_harsh.caf */; };
+ 3E46B514B1D95CD39E91724B /* Loop/Resources/Sounds/electronic_lg_medium.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0F7A07E85B57B85C219B1244 /* Loop/Resources/Sounds/electronic_lg_medium.caf */; };
+ C9489985ABD6FED5A2E5C857 /* Loop/Resources/Sounds/electronic_lg_mild.caf in Resources */ = {isa = PBXBuildFile; fileRef = C6BD81B55371D7597A3D2BCC /* Loop/Resources/Sounds/electronic_lg_mild.caf */; };
+ B63CAEC6D7662917D8EF89A7 /* Loop/Resources/Sounds/electronic_lgs_harsh.caf in Resources */ = {isa = PBXBuildFile; fileRef = 177CE52DCE2BF26A5B8D4FCF /* Loop/Resources/Sounds/electronic_lgs_harsh.caf */; };
+ 746A339E8C3350AB42CC5A16 /* Loop/Resources/Sounds/electronic_lgs_medium.caf in Resources */ = {isa = PBXBuildFile; fileRef = 18566B364CC2520CFBDB2607 /* Loop/Resources/Sounds/electronic_lgs_medium.caf */; };
+ 9695396DE1F34E51C8CF0225 /* Loop/Resources/Sounds/electronic_lgs_mild.caf in Resources */ = {isa = PBXBuildFile; fileRef = A746D58F0F0815133AC12A9D /* Loop/Resources/Sounds/electronic_lgs_mild.caf */; };
+ 06DEBA22295370ED71FA6FF1 /* Loop/Resources/Sounds/electronic_ulg_harsh.caf in Resources */ = {isa = PBXBuildFile; fileRef = EB5FAFC0C4DA8D94009F0EA7 /* Loop/Resources/Sounds/electronic_ulg_harsh.caf */; };
+ 4A1C8ED0D43F5DB88D9E54EF /* Loop/Resources/Sounds/electronic_ulg_medium.caf in Resources */ = {isa = PBXBuildFile; fileRef = 6DEA9E9B5623F8F77822D99C /* Loop/Resources/Sounds/electronic_ulg_medium.caf */; };
+ B47913E810409CB0B7BC6576 /* Loop/Resources/Sounds/electronic_ulg_mild.caf in Resources */ = {isa = PBXBuildFile; fileRef = 780F7D310A3BFB3C3F22694C /* Loop/Resources/Sounds/electronic_ulg_mild.caf */; };
+ 82D4B3B73B633E0CDA8CDF73 /* Loop/Resources/Sounds/glass_bell.caf in Resources */ = {isa = PBXBuildFile; fileRef = 92A703ABE59E0E23337CE6B1 /* Loop/Resources/Sounds/glass_bell.caf */; };
+ ACEDEA57D2D24A7AA7010A7B /* Loop/Resources/Sounds/hard_saw_wave.caf in Resources */ = {isa = PBXBuildFile; fileRef = 8032B80CBB012479E89EBDE0 /* Loop/Resources/Sounds/hard_saw_wave.caf */; };
+ 908A8AAFECDBC70B57169E3D /* Loop/Resources/Sounds/harp.caf in Resources */ = {isa = PBXBuildFile; fileRef = 8767531B981D2B1C3284D95E /* Loop/Resources/Sounds/harp.caf */; };
+ EB2A217C9AD057A7DF01F116 /* Loop/Resources/Sounds/meow.caf in Resources */ = {isa = PBXBuildFile; fileRef = DE9E9CEE9FBE4CDD17605266 /* Loop/Resources/Sounds/meow.caf */; };
+ 706AA94D1D4B49F51B04B9D6 /* Loop/Resources/Sounds/open_can.caf in Resources */ = {isa = PBXBuildFile; fileRef = D212086C7473C12CFAE3EDA7 /* Loop/Resources/Sounds/open_can.caf */; };
+ D87EFB28C39228F73654A56A /* Loop/Resources/Sounds/open_wrapper.caf in Resources */ = {isa = PBXBuildFile; fileRef = 47B3FD13DA5BEB4A2CBBA86C /* Loop/Resources/Sounds/open_wrapper.caf */; };
+ C35102622CBDADC4F8A29531 /* Loop/Resources/Sounds/saw_wave.caf in Resources */ = {isa = PBXBuildFile; fileRef = 8A58B1B290C65AE2DFC96CE6 /* Loop/Resources/Sounds/saw_wave.caf */; };
+ 0C63FC60520110C35B54456A /* Loop/Resources/Sounds/sine_wave.caf in Resources */ = {isa = PBXBuildFile; fileRef = 345BE45BC6D5B01FBA7FE0B4 /* Loop/Resources/Sounds/sine_wave.caf */; };
+ ACC87746A312EF0DF019CE18 /* Loop/Resources/Sounds/voice_hg_amy.caf in Resources */ = {isa = PBXBuildFile; fileRef = 9028674AC1F9A25E599AB2CD /* Loop/Resources/Sounds/voice_hg_amy.caf */; };
+ 29074112CB80B5D0729833D2 /* Loop/Resources/Sounds/voice_hg_bryce.caf in Resources */ = {isa = PBXBuildFile; fileRef = D1C8C12CAA6CF822846035AA /* Loop/Resources/Sounds/voice_hg_bryce.caf */; };
+ B0C24C973471C4B971DF3034 /* Loop/Resources/Sounds/voice_lg_amy.caf in Resources */ = {isa = PBXBuildFile; fileRef = D5394844C0EA517BF83DAC67 /* Loop/Resources/Sounds/voice_lg_amy.caf */; };
+ E23DB8960A0D78A3D5BBBAC1 /* Loop/Resources/Sounds/voice_lg_bryce.caf in Resources */ = {isa = PBXBuildFile; fileRef = 15A11683B521C116718CCF03 /* Loop/Resources/Sounds/voice_lg_bryce.caf */; };
+ CD753CCE4F90A5DF08CD6FE3 /* Loop/Resources/Sounds/voice_lgs_amy.caf in Resources */ = {isa = PBXBuildFile; fileRef = 34C6B095931D004E1C6D1E2A /* Loop/Resources/Sounds/voice_lgs_amy.caf */; };
+ 82F11F858069EDA2DF65D909 /* Loop/Resources/Sounds/voice_lgs_bryce.caf in Resources */ = {isa = PBXBuildFile; fileRef = FB860882201269230F24C96C /* Loop/Resources/Sounds/voice_lgs_bryce.caf */; };
+ 0996021E82CDE7DA7A3DD544 /* Loop/Resources/Sounds/voice_ulg_amy.caf in Resources */ = {isa = PBXBuildFile; fileRef = B08A706BF6BC6555453D3E56 /* Loop/Resources/Sounds/voice_ulg_amy.caf */; };
+ 8BAF071E821EAC3FB1B183B3 /* Loop/Resources/Sounds/voice_ulg_bryce.caf in Resources */ = {isa = PBXBuildFile; fileRef = 1F9BFD4BD97793AAE2407DCB /* Loop/Resources/Sounds/voice_ulg_bryce.caf */; };
1419606928D9554E00BA86E0 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 437AFEE6203688CF008C4892 /* LoopKitUI.framework */; };
1419606A28D955BC00BA86E0 /* MockKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C101947127DD473C004E7EB8 /* MockKitUI.framework */; };
142CB7592A60BF2E0075748A /* EditMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142CB7582A60BF2E0075748A /* EditMode.swift */; };
@@ -334,6 +374,7 @@
9960C6964DD51D81093FAD5D /* Loop/Resources/Sounds/chime.caf in Resources */ = {isa = PBXBuildFile; fileRef = 3F7A1D72697082DC3A14587E /* Loop/Resources/Sounds/chime.caf */; };
9B8FCBDD6E3A9E603C4AABAD /* Loop/Resources/Sounds/bright_alarm.caf in Resources */ = {isa = PBXBuildFile; fileRef = 270F994CD56D566028B3CB25 /* Loop/Resources/Sounds/bright_alarm.caf */; };
A1B2C3D40000000000000002 /* GlucoseAlertManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000001 /* GlucoseAlertManagerTests.swift */; };
+ A1B2C3D40000000000000012 /* AlarmSoundCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000011 /* AlarmSoundCatalogTests.swift */; };
A2779286E117BD3627DA0618 /* Loop/Resources/Sounds/simple.caf in Resources */ = {isa = PBXBuildFile; fileRef = 5181B60CBF3CB2DA537F0475 /* Loop/Resources/Sounds/simple.caf */; };
A413B305CB4C4009DA09BE57 /* Loop/Resources/Sounds/bloom.caf in Resources */ = {isa = PBXBuildFile; fileRef = 8FB2394478FF679F436E8EF1 /* Loop/Resources/Sounds/bloom.caf */; };
A4574F416AD62A1FCDE36D7D /* Loop/Resources/Sounds/high_chimes.caf in Resources */ = {isa = PBXBuildFile; fileRef = 491F8F0D89972E9D8903CFA7 /* Loop/Resources/Sounds/high_chimes.caf */; };
@@ -1144,6 +1185,7 @@
8FB2394478FF679F436E8EF1 /* Loop/Resources/Sounds/bloom.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/bloom.caf; sourceTree = SOURCE_ROOT; };
9471E156FC10D6D0FF219C77 /* Loop/Resources/Sounds/spring.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/spring.caf; sourceTree = SOURCE_ROOT; };
A1B2C3D40000000000000001 /* GlucoseAlertManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlertManagerTests.swift; sourceTree = ""; };
+ A1B2C3D40000000000000011 /* AlarmSoundCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmSoundCatalogTests.swift; sourceTree = ""; };
A900531A28D60862000BC15B /* Loop.shortcut */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop.shortcut; sourceTree = ""; };
A900531B28D608CA000BC15B /* Cancel Override.shortcut */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Cancel Override.shortcut"; sourceTree = ""; };
A900531C28D6090D000BC15B /* Loop Remote Overrides.shortcut */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Loop Remote Overrides.shortcut"; sourceTree = ""; };
@@ -1401,6 +1443,46 @@
C9A47B0375B13A9543390BC7 /* GlucoseAlertSettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseAlertSettingsView.swift; sourceTree = ""; };
C9A47B0475B13A9543390BC8 /* Loop/Resources/Sounds/critical.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/critical.caf; sourceTree = SOURCE_ROOT; };
C9EA3F48338B49BD9C6BD0F9 /* Loop/Resources/Sounds/urgent_low.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/urgent_low.caf; sourceTree = SOURCE_ROOT; };
+ A9B1F564432BCBF70C0C6CBB /* Loop/Resources/Sounds/acoustic_hg_harsh.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/acoustic_hg_harsh.caf; sourceTree = SOURCE_ROOT; };
+ 3481DFC7974E27ABE6AAC4C3 /* Loop/Resources/Sounds/acoustic_hg_medium.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/acoustic_hg_medium.caf; sourceTree = SOURCE_ROOT; };
+ 1DCC85EA42C8D22C1EC824D8 /* Loop/Resources/Sounds/acoustic_hg_mild.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/acoustic_hg_mild.caf; sourceTree = SOURCE_ROOT; };
+ A5E01C8397D139F93B60CA0A /* Loop/Resources/Sounds/acoustic_lg_harsh.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/acoustic_lg_harsh.caf; sourceTree = SOURCE_ROOT; };
+ D64AC8FAA2340810ECB85AE9 /* Loop/Resources/Sounds/acoustic_lg_medium.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/acoustic_lg_medium.caf; sourceTree = SOURCE_ROOT; };
+ 65E7CB20BE6AB6A39AF072A2 /* Loop/Resources/Sounds/acoustic_lg_mild.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/acoustic_lg_mild.caf; sourceTree = SOURCE_ROOT; };
+ 688FE8312F66C4C1909FF93F /* Loop/Resources/Sounds/acoustic_lgs_harsh.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/acoustic_lgs_harsh.caf; sourceTree = SOURCE_ROOT; };
+ E5D3EF6AEBD5FFAF2ABB187B /* Loop/Resources/Sounds/acoustic_lgs_medium.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/acoustic_lgs_medium.caf; sourceTree = SOURCE_ROOT; };
+ 16BE482D6AF35127A36FD9EB /* Loop/Resources/Sounds/acoustic_lgs_mild.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/acoustic_lgs_mild.caf; sourceTree = SOURCE_ROOT; };
+ A96BF0E0B93A88D6BCEC2338 /* Loop/Resources/Sounds/acoustic_ulg_harsh.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/acoustic_ulg_harsh.caf; sourceTree = SOURCE_ROOT; };
+ CC3E2E706E45342C823D641F /* Loop/Resources/Sounds/acoustic_ulg_medium.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/acoustic_ulg_medium.caf; sourceTree = SOURCE_ROOT; };
+ E38835D52BBD5607D90B9615 /* Loop/Resources/Sounds/acoustic_ulg_mild.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/acoustic_ulg_mild.caf; sourceTree = SOURCE_ROOT; };
+ 2D388DFB60E5960C857807D0 /* Loop/Resources/Sounds/electronic_hg_harsh.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/electronic_hg_harsh.caf; sourceTree = SOURCE_ROOT; };
+ 987F0560C0D00A3F1AC4D006 /* Loop/Resources/Sounds/electronic_hg_medium.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/electronic_hg_medium.caf; sourceTree = SOURCE_ROOT; };
+ C8BD9DF4B53E440810325EA6 /* Loop/Resources/Sounds/electronic_hg_mild.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/electronic_hg_mild.caf; sourceTree = SOURCE_ROOT; };
+ FE620B9DFF719ED07CEEA1B4 /* Loop/Resources/Sounds/electronic_lg_harsh.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/electronic_lg_harsh.caf; sourceTree = SOURCE_ROOT; };
+ 0F7A07E85B57B85C219B1244 /* Loop/Resources/Sounds/electronic_lg_medium.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/electronic_lg_medium.caf; sourceTree = SOURCE_ROOT; };
+ C6BD81B55371D7597A3D2BCC /* Loop/Resources/Sounds/electronic_lg_mild.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/electronic_lg_mild.caf; sourceTree = SOURCE_ROOT; };
+ 177CE52DCE2BF26A5B8D4FCF /* Loop/Resources/Sounds/electronic_lgs_harsh.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/electronic_lgs_harsh.caf; sourceTree = SOURCE_ROOT; };
+ 18566B364CC2520CFBDB2607 /* Loop/Resources/Sounds/electronic_lgs_medium.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/electronic_lgs_medium.caf; sourceTree = SOURCE_ROOT; };
+ A746D58F0F0815133AC12A9D /* Loop/Resources/Sounds/electronic_lgs_mild.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/electronic_lgs_mild.caf; sourceTree = SOURCE_ROOT; };
+ EB5FAFC0C4DA8D94009F0EA7 /* Loop/Resources/Sounds/electronic_ulg_harsh.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/electronic_ulg_harsh.caf; sourceTree = SOURCE_ROOT; };
+ 6DEA9E9B5623F8F77822D99C /* Loop/Resources/Sounds/electronic_ulg_medium.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/electronic_ulg_medium.caf; sourceTree = SOURCE_ROOT; };
+ 780F7D310A3BFB3C3F22694C /* Loop/Resources/Sounds/electronic_ulg_mild.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/electronic_ulg_mild.caf; sourceTree = SOURCE_ROOT; };
+ 92A703ABE59E0E23337CE6B1 /* Loop/Resources/Sounds/glass_bell.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/glass_bell.caf; sourceTree = SOURCE_ROOT; };
+ 8032B80CBB012479E89EBDE0 /* Loop/Resources/Sounds/hard_saw_wave.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/hard_saw_wave.caf; sourceTree = SOURCE_ROOT; };
+ 8767531B981D2B1C3284D95E /* Loop/Resources/Sounds/harp.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/harp.caf; sourceTree = SOURCE_ROOT; };
+ DE9E9CEE9FBE4CDD17605266 /* Loop/Resources/Sounds/meow.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/meow.caf; sourceTree = SOURCE_ROOT; };
+ D212086C7473C12CFAE3EDA7 /* Loop/Resources/Sounds/open_can.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/open_can.caf; sourceTree = SOURCE_ROOT; };
+ 47B3FD13DA5BEB4A2CBBA86C /* Loop/Resources/Sounds/open_wrapper.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/open_wrapper.caf; sourceTree = SOURCE_ROOT; };
+ 8A58B1B290C65AE2DFC96CE6 /* Loop/Resources/Sounds/saw_wave.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/saw_wave.caf; sourceTree = SOURCE_ROOT; };
+ 345BE45BC6D5B01FBA7FE0B4 /* Loop/Resources/Sounds/sine_wave.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/sine_wave.caf; sourceTree = SOURCE_ROOT; };
+ 9028674AC1F9A25E599AB2CD /* Loop/Resources/Sounds/voice_hg_amy.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/voice_hg_amy.caf; sourceTree = SOURCE_ROOT; };
+ D1C8C12CAA6CF822846035AA /* Loop/Resources/Sounds/voice_hg_bryce.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/voice_hg_bryce.caf; sourceTree = SOURCE_ROOT; };
+ D5394844C0EA517BF83DAC67 /* Loop/Resources/Sounds/voice_lg_amy.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/voice_lg_amy.caf; sourceTree = SOURCE_ROOT; };
+ 15A11683B521C116718CCF03 /* Loop/Resources/Sounds/voice_lg_bryce.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/voice_lg_bryce.caf; sourceTree = SOURCE_ROOT; };
+ 34C6B095931D004E1C6D1E2A /* Loop/Resources/Sounds/voice_lgs_amy.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/voice_lgs_amy.caf; sourceTree = SOURCE_ROOT; };
+ FB860882201269230F24C96C /* Loop/Resources/Sounds/voice_lgs_bryce.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/voice_lgs_bryce.caf; sourceTree = SOURCE_ROOT; };
+ B08A706BF6BC6555453D3E56 /* Loop/Resources/Sounds/voice_ulg_amy.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/voice_ulg_amy.caf; sourceTree = SOURCE_ROOT; };
+ 1F9BFD4BD97793AAE2407DCB /* Loop/Resources/Sounds/voice_ulg_bryce.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/voice_ulg_bryce.caf; sourceTree = SOURCE_ROOT; };
C9SCENED0000000000000002 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; };
CE102E23D8C1C1291EF66387 /* PluginPickerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PluginPickerView.swift; sourceTree = ""; };
DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegralRetrospectiveCorrectionSelectionView.swift; sourceTree = ""; };
@@ -1621,6 +1703,7 @@
C16B983F26B4898800256B05 /* DoseEnactorTests.swift */,
E9C58A7124DB489100487A17 /* LoopDataManagerTests.swift */,
A1B2C3D40000000000000001 /* GlucoseAlertManagerTests.swift */,
+ A1B2C3D40000000000000011 /* AlarmSoundCatalogTests.swift */,
E9B3552C293592B40076AB04 /* MealDetectionManagerTests.swift */,
C1DA43562B1A70BE00CBD33F /* SettingsManagerTests.swift */,
1D70C40026EC0F9D00C62570 /* SupportManagerTests.swift */,
@@ -2330,6 +2413,46 @@
83081FFB0497A4596F597830 /* Loop/Resources/Sounds/synth.caf */,
21F13EB666EBFDD03F99CB7E /* Loop/Resources/Sounds/trill.caf */,
C9EA3F48338B49BD9C6BD0F9 /* Loop/Resources/Sounds/urgent_low.caf */,
+ A9B1F564432BCBF70C0C6CBB /* Loop/Resources/Sounds/acoustic_hg_harsh.caf */,
+ 3481DFC7974E27ABE6AAC4C3 /* Loop/Resources/Sounds/acoustic_hg_medium.caf */,
+ 1DCC85EA42C8D22C1EC824D8 /* Loop/Resources/Sounds/acoustic_hg_mild.caf */,
+ A5E01C8397D139F93B60CA0A /* Loop/Resources/Sounds/acoustic_lg_harsh.caf */,
+ D64AC8FAA2340810ECB85AE9 /* Loop/Resources/Sounds/acoustic_lg_medium.caf */,
+ 65E7CB20BE6AB6A39AF072A2 /* Loop/Resources/Sounds/acoustic_lg_mild.caf */,
+ 688FE8312F66C4C1909FF93F /* Loop/Resources/Sounds/acoustic_lgs_harsh.caf */,
+ E5D3EF6AEBD5FFAF2ABB187B /* Loop/Resources/Sounds/acoustic_lgs_medium.caf */,
+ 16BE482D6AF35127A36FD9EB /* Loop/Resources/Sounds/acoustic_lgs_mild.caf */,
+ A96BF0E0B93A88D6BCEC2338 /* Loop/Resources/Sounds/acoustic_ulg_harsh.caf */,
+ CC3E2E706E45342C823D641F /* Loop/Resources/Sounds/acoustic_ulg_medium.caf */,
+ E38835D52BBD5607D90B9615 /* Loop/Resources/Sounds/acoustic_ulg_mild.caf */,
+ 2D388DFB60E5960C857807D0 /* Loop/Resources/Sounds/electronic_hg_harsh.caf */,
+ 987F0560C0D00A3F1AC4D006 /* Loop/Resources/Sounds/electronic_hg_medium.caf */,
+ C8BD9DF4B53E440810325EA6 /* Loop/Resources/Sounds/electronic_hg_mild.caf */,
+ FE620B9DFF719ED07CEEA1B4 /* Loop/Resources/Sounds/electronic_lg_harsh.caf */,
+ 0F7A07E85B57B85C219B1244 /* Loop/Resources/Sounds/electronic_lg_medium.caf */,
+ C6BD81B55371D7597A3D2BCC /* Loop/Resources/Sounds/electronic_lg_mild.caf */,
+ 177CE52DCE2BF26A5B8D4FCF /* Loop/Resources/Sounds/electronic_lgs_harsh.caf */,
+ 18566B364CC2520CFBDB2607 /* Loop/Resources/Sounds/electronic_lgs_medium.caf */,
+ A746D58F0F0815133AC12A9D /* Loop/Resources/Sounds/electronic_lgs_mild.caf */,
+ EB5FAFC0C4DA8D94009F0EA7 /* Loop/Resources/Sounds/electronic_ulg_harsh.caf */,
+ 6DEA9E9B5623F8F77822D99C /* Loop/Resources/Sounds/electronic_ulg_medium.caf */,
+ 780F7D310A3BFB3C3F22694C /* Loop/Resources/Sounds/electronic_ulg_mild.caf */,
+ 92A703ABE59E0E23337CE6B1 /* Loop/Resources/Sounds/glass_bell.caf */,
+ 8032B80CBB012479E89EBDE0 /* Loop/Resources/Sounds/hard_saw_wave.caf */,
+ 8767531B981D2B1C3284D95E /* Loop/Resources/Sounds/harp.caf */,
+ DE9E9CEE9FBE4CDD17605266 /* Loop/Resources/Sounds/meow.caf */,
+ D212086C7473C12CFAE3EDA7 /* Loop/Resources/Sounds/open_can.caf */,
+ 47B3FD13DA5BEB4A2CBBA86C /* Loop/Resources/Sounds/open_wrapper.caf */,
+ 8A58B1B290C65AE2DFC96CE6 /* Loop/Resources/Sounds/saw_wave.caf */,
+ 345BE45BC6D5B01FBA7FE0B4 /* Loop/Resources/Sounds/sine_wave.caf */,
+ 9028674AC1F9A25E599AB2CD /* Loop/Resources/Sounds/voice_hg_amy.caf */,
+ D1C8C12CAA6CF822846035AA /* Loop/Resources/Sounds/voice_hg_bryce.caf */,
+ D5394844C0EA517BF83DAC67 /* Loop/Resources/Sounds/voice_lg_amy.caf */,
+ 15A11683B521C116718CCF03 /* Loop/Resources/Sounds/voice_lg_bryce.caf */,
+ 34C6B095931D004E1C6D1E2A /* Loop/Resources/Sounds/voice_lgs_amy.caf */,
+ FB860882201269230F24C96C /* Loop/Resources/Sounds/voice_lgs_bryce.caf */,
+ B08A706BF6BC6555453D3E56 /* Loop/Resources/Sounds/voice_ulg_amy.caf */,
+ 1F9BFD4BD97793AAE2407DCB /* Loop/Resources/Sounds/voice_ulg_bryce.caf */,
);
name = "Recovered References";
sourceTree = "";
@@ -3150,6 +3273,46 @@
CB1AF8155B1761A30ACA5591 /* Loop/Resources/Sounds/synth.caf in Resources */,
16E5A3BC4D931CE6AED18518 /* Loop/Resources/Sounds/trill.caf in Resources */,
124B300527D84BE2A1233A91 /* Loop/Resources/Sounds/urgent_low.caf in Resources */,
+ 7877942ED674BA37847514AD /* Loop/Resources/Sounds/acoustic_hg_harsh.caf in Resources */,
+ A82DB18B916DC17D2E180D59 /* Loop/Resources/Sounds/acoustic_hg_medium.caf in Resources */,
+ 4A208427654F9A249EC2CFDB /* Loop/Resources/Sounds/acoustic_hg_mild.caf in Resources */,
+ F44BED105495ABA988BAB05F /* Loop/Resources/Sounds/acoustic_lg_harsh.caf in Resources */,
+ B0007FE66FB0FE886FACD77D /* Loop/Resources/Sounds/acoustic_lg_medium.caf in Resources */,
+ D0EBF80B1D85F96B156725AA /* Loop/Resources/Sounds/acoustic_lg_mild.caf in Resources */,
+ 3461DE9DBC0B97D5662244FB /* Loop/Resources/Sounds/acoustic_lgs_harsh.caf in Resources */,
+ F98A16085E9F216CEDDB18DB /* Loop/Resources/Sounds/acoustic_lgs_medium.caf in Resources */,
+ 182579BE820905E2B9FCC1B4 /* Loop/Resources/Sounds/acoustic_lgs_mild.caf in Resources */,
+ 6AB8817E7C134FE630492390 /* Loop/Resources/Sounds/acoustic_ulg_harsh.caf in Resources */,
+ BAD8333EC083569038F10E88 /* Loop/Resources/Sounds/acoustic_ulg_medium.caf in Resources */,
+ 6E0CBD04FE69B38497EFC6F0 /* Loop/Resources/Sounds/acoustic_ulg_mild.caf in Resources */,
+ DB1B4A63BB3DE5DF05D56791 /* Loop/Resources/Sounds/electronic_hg_harsh.caf in Resources */,
+ 0EA4FC2C9D24030B0D586F80 /* Loop/Resources/Sounds/electronic_hg_medium.caf in Resources */,
+ 09789816D7760CF9BAA96F28 /* Loop/Resources/Sounds/electronic_hg_mild.caf in Resources */,
+ D1530992424BA31F0A44EA27 /* Loop/Resources/Sounds/electronic_lg_harsh.caf in Resources */,
+ 3E46B514B1D95CD39E91724B /* Loop/Resources/Sounds/electronic_lg_medium.caf in Resources */,
+ C9489985ABD6FED5A2E5C857 /* Loop/Resources/Sounds/electronic_lg_mild.caf in Resources */,
+ B63CAEC6D7662917D8EF89A7 /* Loop/Resources/Sounds/electronic_lgs_harsh.caf in Resources */,
+ 746A339E8C3350AB42CC5A16 /* Loop/Resources/Sounds/electronic_lgs_medium.caf in Resources */,
+ 9695396DE1F34E51C8CF0225 /* Loop/Resources/Sounds/electronic_lgs_mild.caf in Resources */,
+ 06DEBA22295370ED71FA6FF1 /* Loop/Resources/Sounds/electronic_ulg_harsh.caf in Resources */,
+ 4A1C8ED0D43F5DB88D9E54EF /* Loop/Resources/Sounds/electronic_ulg_medium.caf in Resources */,
+ B47913E810409CB0B7BC6576 /* Loop/Resources/Sounds/electronic_ulg_mild.caf in Resources */,
+ 82D4B3B73B633E0CDA8CDF73 /* Loop/Resources/Sounds/glass_bell.caf in Resources */,
+ ACEDEA57D2D24A7AA7010A7B /* Loop/Resources/Sounds/hard_saw_wave.caf in Resources */,
+ 908A8AAFECDBC70B57169E3D /* Loop/Resources/Sounds/harp.caf in Resources */,
+ EB2A217C9AD057A7DF01F116 /* Loop/Resources/Sounds/meow.caf in Resources */,
+ 706AA94D1D4B49F51B04B9D6 /* Loop/Resources/Sounds/open_can.caf in Resources */,
+ D87EFB28C39228F73654A56A /* Loop/Resources/Sounds/open_wrapper.caf in Resources */,
+ C35102622CBDADC4F8A29531 /* Loop/Resources/Sounds/saw_wave.caf in Resources */,
+ 0C63FC60520110C35B54456A /* Loop/Resources/Sounds/sine_wave.caf in Resources */,
+ ACC87746A312EF0DF019CE18 /* Loop/Resources/Sounds/voice_hg_amy.caf in Resources */,
+ 29074112CB80B5D0729833D2 /* Loop/Resources/Sounds/voice_hg_bryce.caf in Resources */,
+ B0C24C973471C4B971DF3034 /* Loop/Resources/Sounds/voice_lg_amy.caf in Resources */,
+ E23DB8960A0D78A3D5BBBAC1 /* Loop/Resources/Sounds/voice_lg_bryce.caf in Resources */,
+ CD753CCE4F90A5DF08CD6FE3 /* Loop/Resources/Sounds/voice_lgs_amy.caf in Resources */,
+ 82F11F858069EDA2DF65D909 /* Loop/Resources/Sounds/voice_lgs_bryce.caf in Resources */,
+ 0996021E82CDE7DA7A3DD544 /* Loop/Resources/Sounds/voice_ulg_amy.caf in Resources */,
+ 8BAF071E821EAC3FB1B183B3 /* Loop/Resources/Sounds/voice_ulg_bryce.caf in Resources */,
B405E35A24D2B1A400DD058D /* HUDAssets.xcassets in Resources */,
1F0000012FBA000100000001 /* AppIcon.icon in Resources */,
A966152623EA5A26005D8B29 /* DefaultAssets.xcassets in Resources */,
@@ -3865,6 +4028,7 @@
C18859A42AF165330010F21F /* MockCGMManager.swift in Sources */,
E9C58A7324DB4A2700487A17 /* LoopDataManagerTests.swift in Sources */,
A1B2C3D40000000000000002 /* GlucoseAlertManagerTests.swift in Sources */,
+ A1B2C3D40000000000000012 /* AlarmSoundCatalogTests.swift in Sources */,
C18859A22AF165130010F21F /* MockPumpManager.swift in Sources */,
E98A55F324EDD9530008715D /* MockSettingsStore.swift in Sources */,
C165756F2534C468004AE16E /* SimpleBolusViewModelTests.swift in Sources */,
diff --git a/Loop/Managers/Alerts/CriticalAlertAudioPlayer.swift b/Loop/Managers/Alerts/CriticalAlertAudioPlayer.swift
index 8c673d062..a8f7fbf6e 100644
--- a/Loop/Managers/Alerts/CriticalAlertAudioPlayer.swift
+++ b/Loop/Managers/Alerts/CriticalAlertAudioPlayer.swift
@@ -51,12 +51,29 @@ final class CriticalAlertAudioPlayer {
/// `SystemVolumeBooster`.
private let volumeBooster = SystemVolumeBooster()
- /// How long the alarm plays before stopping on its own. A short burst
- /// (a few loops of the ~0.84s tone) is attention-grabbing without being
- /// a runaway siren — the user-visible UNNotification persists, and the
- /// alert re-fires on the next critical-alert trigger if the condition
- /// holds.
- private let maxDuration: TimeInterval = 6
+ /// Shortest the alarm plays before stopping on its own. A short burst is
+ /// attention-grabbing without being a runaway siren — the user-visible
+ /// UNNotification persists, and the alert re-fires on the next
+ /// critical-alert trigger if the condition holds.
+ ///
+ /// It is a floor, not a cap: the burst runs whole passes of the sound and
+ /// keeps going until it has reached this, so a 0.84s tone repeats about
+ /// eight times while a 10.8s alarm plays through once. Cutting a long
+ /// alarm off mid-phrase would throw away the part of it that carries the
+ /// urgency, which is why the length can't simply be fixed here.
+ static let minBurstDuration: TimeInterval = 6
+
+ /// Hard ceiling on a burst, so an unexpectedly long sound can't turn into
+ /// a siren that runs for minutes. Also the longest sound AlarmKit and
+ /// `UNNotificationSound` will play, so nothing in the catalog reaches it.
+ static let maxBurstDuration: TimeInterval = 30
+
+ /// Whole passes of `duration`, enough to reach `minBurstDuration`, capped.
+ nonisolated static func burstDuration(forSoundOfDuration duration: TimeInterval) -> TimeInterval {
+ guard duration > 0 else { return minBurstDuration }
+ let passes = max(1, Int((minBurstDuration / duration).rounded(.up)))
+ return min(maxBurstDuration, duration * Double(passes))
+ }
/// True if we're currently looping audio for a critical alert.
var isPlaying: Bool { player?.isPlaying ?? false }
@@ -78,7 +95,9 @@ final class CriticalAlertAudioPlayer {
// everything (audio + vibration) to maxDuration, so the alarm still
// buzzes even if audio setup below fails.
startVibration()
- stopTimer = Timer.scheduledTimer(withTimeInterval: maxDuration, repeats: false) { [weak self] _ in
+ // Bounds vibration if the audio setup below fails. Re-armed to the
+ // sound's own burst length once we know how long it is.
+ stopTimer = Timer.scheduledTimer(withTimeInterval: Self.minBurstDuration, repeats: false) { [weak self] _ in
Task { @MainActor in self?.stop() }
}
@@ -128,14 +147,13 @@ final class CriticalAlertAudioPlayer {
return
}
player = p
- if !boostVolume {
- stopTimer?.invalidate()
- stopTimer = Timer.scheduledTimer(withTimeInterval: p.duration, repeats: false) { [weak self] _ in
- Task { @MainActor in self?.stop() }
- }
+ let burst = boostVolume ? Self.burstDuration(forSoundOfDuration: p.duration) : p.duration
+ stopTimer?.invalidate()
+ stopTimer = Timer.scheduledTimer(withTimeInterval: burst, repeats: false) { [weak self] _ in
+ Task { @MainActor in self?.stop() }
}
- os_log("Started critical-alert audio playback (duration=%{public}.2fs playing=%{public}@)",
- log: log, type: .info, p.duration, p.isPlaying ? "yes" : "no")
+ os_log("Started critical-alert audio playback (duration=%{public}.2fs burst=%{public}.2fs playing=%{public}@)",
+ log: log, type: .info, p.duration, burst, p.isPlaying ? "yes" : "no")
} catch {
os_log("Failed to start audio playback: %{public}@", log: log, type: .error, String(describing: error))
}
diff --git a/Loop/Managers/Alerts/GlucoseAlertManager.swift b/Loop/Managers/Alerts/GlucoseAlertManager.swift
index ea675695e..18fbf50e3 100644
--- a/Loop/Managers/Alerts/GlucoseAlertManager.swift
+++ b/Loop/Managers/Alerts/GlucoseAlertManager.swift
@@ -17,13 +17,125 @@ import os.log
// MARK: - Sound catalog
-/// One selectable alarm sound. `filename` is the bundled `.caf` (also the
-/// `Alert.Sound` name stamped onto issued alerts); `displayName` is what the
-/// picker shows.
+/// The glucose alarms a sound can be chosen for. Themed sets are written per
+/// alarm — the Urgent Low set is not the High set at another pitch — so each
+/// set is only offered on the alarm it was written for.
+enum GlucoseAlarmKind: String, CaseIterable {
+ case urgentLow
+ case low
+ case predictedLow
+ case high
+
+ var displayName: String {
+ switch self {
+ case .urgentLow: return NSLocalizedString("Urgent Low", comment: "Name of the urgent low glucose alarm")
+ case .low: return NSLocalizedString("Low", comment: "Name of the low glucose alarm")
+ case .predictedLow: return NSLocalizedString("Predicted Low", comment: "Name of the predicted low glucose alarm")
+ case .high: return NSLocalizedString("High", comment: "Name of the high glucose alarm")
+ }
+ }
+
+ /// Identifier of the alert this alarm issues.
+ var alertIdentifier: Alert.AlertIdentifier {
+ switch self {
+ case .urgentLow: return GlucoseAlertManager.urgentLowAlertIdentifier
+ case .low: return GlucoseAlertManager.lowAlertIdentifier
+ case .predictedLow: return GlucoseAlertManager.predictedLowAlertIdentifier
+ case .high: return GlucoseAlertManager.highAlertIdentifier
+ }
+ }
+
+ /// Identifier of this alarm's armed *test* alert. Deliberately distinct
+ /// from the real one: a test then can't touch the real alarm's episode
+ /// state, its retraction, or its AlarmKit alarm.
+ var testAlertIdentifier: Alert.AlertIdentifier { "\(alertIdentifier).test" }
+
+ /// Matches the real alarm's level. Getting past Focus and the silent
+ /// switch is the whole point of the test, so the test has to ask for
+ /// delivery on exactly the same terms.
+ var interruptionLevel: Alert.InterruptionLevel {
+ self == .urgentLow ? .critical : .timeSensitive
+ }
+
+ /// The alarm an alert identifier belongs to, real or test.
+ static func kind(forAlertIdentifier identifier: Alert.AlertIdentifier) -> GlucoseAlarmKind? {
+ allCases.first { $0.alertIdentifier == identifier || $0.testAlertIdentifier == identifier }
+ }
+}
+
+/// How disruptive a themed alarm sounds. Intensity is carried by timbre,
+/// loudness and length together — each level is its own recording, not one
+/// recording at three gain settings.
+enum AlarmUrgencyLevel: String, CaseIterable, Identifiable {
+ case mild
+ case medium
+ case harsh
+
+ var id: String { rawValue }
+
+ var displayName: String {
+ switch self {
+ case .mild: return NSLocalizedString("Mild", comment: "Urgency level of a themed alarm sound")
+ case .medium: return NSLocalizedString("Medium", comment: "Urgency level of a themed alarm sound")
+ case .harsh: return NSLocalizedString("Harsh", comment: "Urgency level of a themed alarm sound")
+ }
+ }
+}
+
+/// Spoken alert voices.
+enum AlarmVoice: String, CaseIterable, Identifiable {
+ case amy
+ case bryce
+
+ var id: String { rawValue }
+
+ var displayName: String {
+ switch self {
+ case .amy: return NSLocalizedString("Amy", comment: "Name of a spoken alarm voice")
+ case .bryce: return NSLocalizedString("Bryce", comment: "Name of a spoken alarm voice")
+ }
+ }
+}
+
+/// One selectable variant of a sound entry. `filename` is the bundled `.caf`
+/// (also the `Alert.Sound` name stamped onto issued alerts) and is the only
+/// thing persisted, so a stored selection survives any catalog reshuffle.
struct AlarmSound: Identifiable, Equatable {
+ let id: String
+ let displayName: String
let filename: String
+}
+
+/// What the picker reveals once a themed entry is selected.
+enum AlarmSoundSelector {
+ case none
+ case urgency
+ case voice
+}
+
+/// One row in the sound picker. A themed entry carries several variants
+/// behind one name (Acoustic at three urgency levels, Voice in two voices);
+/// a generic tone carries exactly one.
+struct AlarmSoundEntry: Identifiable, Equatable {
+ let id: String
let displayName: String
- var id: String { filename }
+ /// Empty means generic: offered on every alarm, under "Other Sounds".
+ let kinds: [GlucoseAlarmKind]
+ let selector: AlarmSoundSelector
+ let variants: [AlarmSound]
+
+ var isGeneric: Bool { kinds.isEmpty }
+ var filenames: [String] { variants.map { $0.filename } }
+
+ func variant(forFilename filename: String) -> AlarmSound? {
+ variants.first { $0.filename == filename }
+ }
+
+ /// Middle of the urgency slider, and the set the sound designer
+ /// recommends after hallway testing with PWDs and caregivers.
+ var defaultVariant: AlarmSound {
+ variants.first { $0.id == AlarmUrgencyLevel.medium.rawValue } ?? variants[0]
+ }
}
/// The sounds available for glucose alarms. Each file is bundled in
@@ -32,29 +144,110 @@ struct AlarmSound: Identifiable, Equatable {
/// critical fallback) and by the notification path once copied into
/// `Library/Sounds` via the AlertSoundVendor mechanism.
enum AlarmSoundCatalog {
- static let all: [AlarmSound] = [
- AlarmSound(filename: "urgent_low.caf", displayName: "Urgent Low"),
- AlarmSound(filename: "critical.caf", displayName: "Sharp Alarm"),
- AlarmSound(filename: "alarm.caf", displayName: "Alarm"),
- AlarmSound(filename: "bright_alarm.caf", displayName: "Bright Alarm"),
- AlarmSound(filename: "honk.caf", displayName: "Honk"),
- AlarmSound(filename: "chime.caf", displayName: "Chime"),
- AlarmSound(filename: "clear_chimes.caf", displayName: "Clear Chimes"),
- AlarmSound(filename: "high_chimes.caf", displayName: "High Chimes"),
- AlarmSound(filename: "dings.caf", displayName: "Dings"),
- AlarmSound(filename: "trill.caf", displayName: "Trill"),
- AlarmSound(filename: "bloom.caf", displayName: "Bloom"),
- AlarmSound(filename: "bloop.caf", displayName: "Bloop"),
- AlarmSound(filename: "spring.caf", displayName: "Spring"),
- AlarmSound(filename: "minimal.caf", displayName: "Minimal"),
- AlarmSound(filename: "simple.caf", displayName: "Simple"),
- AlarmSound(filename: "synth.caf", displayName: "Synth"),
- AlarmSound(filename: "mood_synth.caf", displayName: "Mood Synth"),
- AlarmSound(filename: "crying.caf", displayName: "Crying"),
+ /// Filename slug per alarm, matching the sound designer's prefixes.
+ private static let slugs: [(kind: GlucoseAlarmKind, slug: String)] = [
+ (.urgentLow, "ulg"),
+ (.low, "lg"),
+ (.predictedLow, "lgs"),
+ (.high, "hg"),
]
+ private static func themed(_ theme: String, _ displayName: String) -> [AlarmSoundEntry] {
+ slugs.map { kind, slug in
+ AlarmSoundEntry(
+ id: "\(theme).\(slug)",
+ displayName: displayName,
+ kinds: [kind],
+ selector: .urgency,
+ variants: AlarmUrgencyLevel.allCases.map {
+ AlarmSound(id: $0.rawValue, displayName: $0.displayName, filename: "\(theme)_\(slug)_\($0.rawValue).caf")
+ }
+ )
+ }
+ }
+
+ private static let voiceEntries: [AlarmSoundEntry] = slugs.map { kind, slug in
+ AlarmSoundEntry(
+ id: "voice.\(slug)",
+ displayName: NSLocalizedString("Voice", comment: "Name of the spoken alarm sound set"),
+ kinds: [kind],
+ selector: .voice,
+ variants: AlarmVoice.allCases.map {
+ AlarmSound(id: $0.rawValue, displayName: $0.displayName, filename: "voice_\(slug)_\($0.rawValue).caf")
+ }
+ )
+ }
+
+ private static let genericEntries: [AlarmSoundEntry] = [
+ ("urgent_low.caf", NSLocalizedString("Urgent Low", comment: "Name of a glucose alarm sound")),
+ ("critical.caf", NSLocalizedString("Sharp Alarm", comment: "Name of a glucose alarm sound")),
+ ("alarm.caf", NSLocalizedString("Alarm", comment: "Name of a glucose alarm sound")),
+ ("bright_alarm.caf", NSLocalizedString("Bright Alarm", comment: "Name of a glucose alarm sound")),
+ ("honk.caf", NSLocalizedString("Honk", comment: "Name of a glucose alarm sound")),
+ ("chime.caf", NSLocalizedString("Chime", comment: "Name of a glucose alarm sound")),
+ ("clear_chimes.caf", NSLocalizedString("Clear Chimes", comment: "Name of a glucose alarm sound")),
+ ("high_chimes.caf", NSLocalizedString("High Chimes", comment: "Name of a glucose alarm sound")),
+ ("dings.caf", NSLocalizedString("Dings", comment: "Name of a glucose alarm sound")),
+ ("trill.caf", NSLocalizedString("Trill", comment: "Name of a glucose alarm sound")),
+ ("bloom.caf", NSLocalizedString("Bloom", comment: "Name of a glucose alarm sound")),
+ ("bloop.caf", NSLocalizedString("Bloop", comment: "Name of a glucose alarm sound")),
+ ("spring.caf", NSLocalizedString("Spring", comment: "Name of a glucose alarm sound")),
+ ("minimal.caf", NSLocalizedString("Minimal", comment: "Name of a glucose alarm sound")),
+ ("simple.caf", NSLocalizedString("Simple", comment: "Name of a glucose alarm sound")),
+ ("synth.caf", NSLocalizedString("Synth", comment: "Name of a glucose alarm sound")),
+ ("mood_synth.caf", NSLocalizedString("Mood Synth", comment: "Name of a glucose alarm sound")),
+ ("crying.caf", NSLocalizedString("Crying", comment: "Name of a glucose alarm sound")),
+ ("glass_bell.caf", NSLocalizedString("Glass Bell", comment: "Name of a glucose alarm sound")),
+ ("harp.caf", NSLocalizedString("Harp", comment: "Name of a glucose alarm sound")),
+ ("meow.caf", NSLocalizedString("Meow", comment: "Name of a glucose alarm sound")),
+ ("saw_wave.caf", NSLocalizedString("Saw Wave", comment: "Name of a glucose alarm sound")),
+ ("hard_saw_wave.caf", NSLocalizedString("Hard Saw Wave", comment: "Name of a glucose alarm sound")),
+ ("sine_wave.caf", NSLocalizedString("Sine Wave", comment: "Name of a glucose alarm sound")),
+ ("open_can.caf", NSLocalizedString("Open Can", comment: "Name of a glucose alarm sound")),
+ ("open_wrapper.caf", NSLocalizedString("Open Wrapper", comment: "Name of a glucose alarm sound")),
+ ].map { filename, name in
+ AlarmSoundEntry(
+ id: filename,
+ displayName: name,
+ kinds: [],
+ selector: .none,
+ variants: [AlarmSound(id: "default", displayName: name, filename: filename)]
+ )
+ }
+
+ static let entries: [AlarmSoundEntry] = themed("acoustic", NSLocalizedString("Acoustic", comment: "Name of the acoustic themed alarm sound set"))
+ + themed("electronic", NSLocalizedString("Electronic", comment: "Name of the electronic themed alarm sound set"))
+ + voiceEntries
+ + genericEntries
+
+ static let allFilenames: [String] = entries.flatMap { $0.filenames }
+
+ /// Themed sets written for this alarm, in display order.
+ static func themedEntries(for kind: GlucoseAlarmKind) -> [AlarmSoundEntry] {
+ entries.filter { $0.kinds.contains(kind) }
+ }
+
+ /// Generic tones, offered on every alarm.
+ static var genericTones: [AlarmSoundEntry] { genericEntries }
+
+ static func entry(forFilename filename: String) -> AlarmSoundEntry? {
+ entries.first { $0.filenames.contains(filename) }
+ }
+
+ /// The themed default for an alarm: Acoustic, Medium.
+ static func defaultFilename(for kind: GlucoseAlarmKind) -> String {
+ themedEntries(for: kind).first { $0.id.hasPrefix("acoustic.") }?.defaultVariant.filename ?? "alarm.caf"
+ }
+
static func displayName(for filename: String) -> String {
- all.first { $0.filename == filename }?.displayName ?? filename
+ guard let entry = entry(forFilename: filename) else { return filename }
+ guard !entry.isGeneric, let variant = entry.variant(forFilename: filename) else {
+ return entry.displayName
+ }
+ return String(
+ format: NSLocalizedString("%1$@ · %2$@", comment: "An alarm sound's set name and the chosen variant (1: set name, 2: variant name)"),
+ entry.displayName, variant.displayName
+ )
}
}
@@ -231,13 +424,14 @@ final class GlucoseAlertManager: ObservableObject {
private static let highPlaysThroughSilentKey = "GlucoseAlertHighPlaysThroughSilent"
private static let predictedLowPlaysThroughSilentKey = "GlucoseAlertPredictedLowPlaysThroughSilent"
private static let episodeStateKey = "GlucoseAlertEpisodeState"
-
- // Per-alarm sound defaults. Urgent low keeps the loud critical tone;
- // the rest get a gentler default the user can change.
- static let defaultUrgentLowSound = "urgent_low.caf"
- static let defaultLowSound = "simple.caf"
- static let defaultHighSound = "chime.caf"
- static let defaultPredictedLowSound = "mood_synth.caf"
+ private static let armedTestKey = "GlucoseAlertArmedTest"
+
+ // Per-alarm sound defaults: each alarm's own Acoustic set at Medium, the
+ // variant the sound designer recommends after testing with PWDs.
+ static let defaultUrgentLowSound = AlarmSoundCatalog.defaultFilename(for: .urgentLow)
+ static let defaultLowSound = AlarmSoundCatalog.defaultFilename(for: .low)
+ static let defaultHighSound = AlarmSoundCatalog.defaultFilename(for: .high)
+ static let defaultPredictedLowSound = AlarmSoundCatalog.defaultFilename(for: .predictedLow)
static let defaultLowPlaysThroughSilent = true
static let defaultHighPlaysThroughSilent = false
static let defaultPredictedLowPlaysThroughSilent = true
@@ -302,22 +496,22 @@ final class GlucoseAlertManager: ObservableObject {
}
func playsThroughSilent(for identifier: Alert.AlertIdentifier) -> Bool {
- switch identifier {
- case Self.lowAlertIdentifier: return lowPlaysThroughSilent
- case Self.highAlertIdentifier: return highPlaysThroughSilent
- case Self.predictedLowAlertIdentifier: return predictedLowPlaysThroughSilent
- default: return false
+ switch GlucoseAlarmKind.kind(forAlertIdentifier: identifier) {
+ case .low: return lowPlaysThroughSilent
+ case .high: return highPlaysThroughSilent
+ case .predictedLow: return predictedLowPlaysThroughSilent
+ case .urgentLow, nil: return false
}
}
/// The configured sound filename for a given glucose alarm identifier.
func configuredSoundName(for identifier: Alert.AlertIdentifier) -> String {
- switch identifier {
- case Self.urgentLowAlertIdentifier: return urgentLowSound
- case Self.lowAlertIdentifier: return lowSound
- case Self.highAlertIdentifier: return highSound
- case Self.predictedLowAlertIdentifier: return predictedLowSound
- default: return Self.defaultUrgentLowSound
+ switch GlucoseAlarmKind.kind(forAlertIdentifier: identifier) {
+ case .urgentLow: return urgentLowSound
+ case .low: return lowSound
+ case .high: return highSound
+ case .predictedLow: return predictedLowSound
+ case nil: return Self.defaultUrgentLowSound
}
}
@@ -493,7 +687,24 @@ final class GlucoseAlertManager: ObservableObject {
private var latestReading: (mgdl: Double, date: Date)?
private var cancellables = Set()
- @Published private(set) var pendingTestUrgentLow: Bool = false
+ /// The alarm whose test is armed, if any. One at a time; arming another
+ /// replaces it. The test waits for the next CGM reading (or the next loop
+ /// cycle, for Predicted Low) so it is delivered from whatever state Loop
+ /// is actually in — backgrounded, screen locked, phone in a Focus — which
+ /// is the thing the user is trying to check.
+ ///
+ /// Persisted, because iOS may terminate a backgrounded Loop before the
+ /// next reading arrives, and a test that quietly evaporates reads exactly
+ /// like an alarm that doesn't work.
+ @Published private(set) var armedTest: GlucoseAlarmKind? {
+ didSet {
+ if let armedTest {
+ userDefaults.set(armedTest.rawValue, forKey: Self.armedTestKey)
+ } else {
+ userDefaults.removeObject(forKey: Self.armedTestKey)
+ }
+ }
+ }
// MARK: - Init
@@ -569,6 +780,8 @@ final class GlucoseAlertManager: ObservableObject {
for key in keysToRemove { userDefaults.removeObject(forKey: key) }
}
+ self.armedTest = userDefaults.string(forKey: Self.armedTestKey).flatMap(GlucoseAlarmKind.init(rawValue:))
+
if let data = userDefaults.data(forKey: Self.episodeStateKey),
let saved = try? JSONDecoder().decode(EpisodeState.self, from: data) {
lowState = saved.low
@@ -587,12 +800,47 @@ final class GlucoseAlertManager: ObservableObject {
// MARK: - Test support
- func armTestUrgentLowOnNextReading() {
- pendingTestUrgentLow = true
- os_log("Test: armed", log: log, type: .info)
+ /// Arm a test of one alarm, to be delivered alongside the next reading.
+ func armTest(for kind: GlucoseAlarmKind) {
+ armedTest = kind
+ os_log("Test armed for %{public}@", log: log, type: .info, kind.rawValue)
}
- func cancelTestUrgentLow() { pendingTestUrgentLow = false }
+ func cancelArmedTest() { armedTest = nil }
+
+ /// Issue the armed test alert for `kind`, if that's the one armed.
+ ///
+ /// The test alert carries the alarm's configured sound and its real
+ /// interruption level — critical for Urgent Low, time-sensitive for the
+ /// rest — so it exercises the same delivery path, including the AlarmKit
+ /// and in-app audio fallbacks. It carries its own identifier and is issued
+ /// in addition to (never in place of) the normal evaluation, so no reading
+ /// is substituted or discarded and no real alarm's state is touched.
+ private func fireArmedTest(for kind: GlucoseAlarmKind) async {
+ guard armedTest == kind else { return }
+ armedTest = nil
+ let content = Alert.Content(
+ title: String(
+ format: NSLocalizedString("%1$@ Alert Test", comment: "Title of a test glucose alert (1: alarm name)"),
+ kind.displayName
+ ),
+ body: NSLocalizedString(
+ "This is a test of this alarm's sound and delivery. Your glucose has not crossed the level.",
+ comment: "Body of a test glucose alert"
+ ),
+ acknowledgeActionButtonLabel: NSLocalizedString("OK", comment: "Acknowledge button on a test glucose alert")
+ )
+ let alert = Alert(
+ identifier: Alert.Identifier(managerIdentifier: Self.managerIdentifier, alertIdentifier: kind.testAlertIdentifier),
+ foregroundContent: content,
+ backgroundContent: content,
+ trigger: .immediate,
+ interruptionLevel: kind.interruptionLevel,
+ sound: .sound(name: configuredSoundName(for: kind.testAlertIdentifier))
+ )
+ os_log("Firing armed test for %{public}@", log: log, type: .info, kind.rawValue)
+ await alertIssuer.issueAlert(alert)
+ }
// MARK: - Persistence
@@ -627,13 +875,7 @@ final class GlucoseAlertManager: ObservableObject {
}
latestReading = (latest.quantity.doubleValue(for: .milligramsPerDeciliter), latest.date)
let config = activeConfiguration(at: now)
- let mgdl: Double
- if pendingTestUrgentLow {
- mgdl = max(20, config.urgentLowThresholdMgDL - 5)
- pendingTestUrgentLow = false
- } else {
- mgdl = latest.quantity.doubleValue(for: .milligramsPerDeciliter)
- }
+ let mgdl = latest.quantity.doubleValue(for: .milligramsPerDeciliter)
// Low and urgent-low are mutually exclusive: a reading below the
// urgent-low threshold is also below the low threshold, so issuing both
// in the same check is redundant. As a rule, only surface the most
@@ -642,6 +884,13 @@ final class GlucoseAlertManager: ObservableObject {
await evaluateLow(mgdl: mgdl, config: config, now: now, supersededByUrgentLow: urgentLowSupersedes)
await evaluateUrgentLow(mgdl: mgdl, config: config, now: now)
await evaluateHigh(mgdl: mgdl, config: config, now: now)
+
+ // An armed test rides along with a real reading so it arrives from
+ // whatever state Loop is in at that moment, but it is issued on its own
+ // identifier and leaves the evaluation above untouched.
+ if let armed = armedTest, armed != .predictedLow {
+ await fireArmedTest(for: armed)
+ }
}
private func evaluateLow(mgdl: Double, config: GlucoseAlertConfiguration, now: Date, supersededByUrgentLow: Bool) async {
@@ -725,6 +974,12 @@ final class GlucoseAlertManager: ObservableObject {
let config = activeConfiguration(at: now)
guard config.predictedLowEnabled else { return }
+ // Predicted Low has no reading of its own to ride along with; its test
+ // goes out on the next completed loop cycle instead.
+ if armedTest == .predictedLow {
+ await fireArmedTest(for: .predictedLow)
+ }
+
func retractIfActive() async {
guard predictedLowInEpisode else { return }
predictedLowInEpisode = false
@@ -819,7 +1074,7 @@ extension GlucoseAlertManager: AlertSoundVendor {
}
nonisolated func getSounds() -> [Alert.Sound] {
- AlarmSoundCatalog.all.map { .sound(name: $0.filename) }
+ AlarmSoundCatalog.allFilenames.map { .sound(name: $0) }
}
}
diff --git a/Loop/Resources/Sounds/acoustic_hg_harsh.caf b/Loop/Resources/Sounds/acoustic_hg_harsh.caf
new file mode 100644
index 000000000..a655e0a83
Binary files /dev/null and b/Loop/Resources/Sounds/acoustic_hg_harsh.caf differ
diff --git a/Loop/Resources/Sounds/acoustic_hg_medium.caf b/Loop/Resources/Sounds/acoustic_hg_medium.caf
new file mode 100644
index 000000000..0912b1a8e
Binary files /dev/null and b/Loop/Resources/Sounds/acoustic_hg_medium.caf differ
diff --git a/Loop/Resources/Sounds/acoustic_hg_mild.caf b/Loop/Resources/Sounds/acoustic_hg_mild.caf
new file mode 100644
index 000000000..d65bcf217
Binary files /dev/null and b/Loop/Resources/Sounds/acoustic_hg_mild.caf differ
diff --git a/Loop/Resources/Sounds/acoustic_lg_harsh.caf b/Loop/Resources/Sounds/acoustic_lg_harsh.caf
new file mode 100644
index 000000000..486a4672b
Binary files /dev/null and b/Loop/Resources/Sounds/acoustic_lg_harsh.caf differ
diff --git a/Loop/Resources/Sounds/acoustic_lg_medium.caf b/Loop/Resources/Sounds/acoustic_lg_medium.caf
new file mode 100644
index 000000000..057db1cf7
Binary files /dev/null and b/Loop/Resources/Sounds/acoustic_lg_medium.caf differ
diff --git a/Loop/Resources/Sounds/acoustic_lg_mild.caf b/Loop/Resources/Sounds/acoustic_lg_mild.caf
new file mode 100644
index 000000000..4c8462a8f
Binary files /dev/null and b/Loop/Resources/Sounds/acoustic_lg_mild.caf differ
diff --git a/Loop/Resources/Sounds/acoustic_lgs_harsh.caf b/Loop/Resources/Sounds/acoustic_lgs_harsh.caf
new file mode 100644
index 000000000..10e2b0ade
Binary files /dev/null and b/Loop/Resources/Sounds/acoustic_lgs_harsh.caf differ
diff --git a/Loop/Resources/Sounds/acoustic_lgs_medium.caf b/Loop/Resources/Sounds/acoustic_lgs_medium.caf
new file mode 100644
index 000000000..72aaeeb1a
Binary files /dev/null and b/Loop/Resources/Sounds/acoustic_lgs_medium.caf differ
diff --git a/Loop/Resources/Sounds/acoustic_lgs_mild.caf b/Loop/Resources/Sounds/acoustic_lgs_mild.caf
new file mode 100644
index 000000000..e7c3f0d64
Binary files /dev/null and b/Loop/Resources/Sounds/acoustic_lgs_mild.caf differ
diff --git a/Loop/Resources/Sounds/acoustic_ulg_harsh.caf b/Loop/Resources/Sounds/acoustic_ulg_harsh.caf
new file mode 100644
index 000000000..daca5e796
Binary files /dev/null and b/Loop/Resources/Sounds/acoustic_ulg_harsh.caf differ
diff --git a/Loop/Resources/Sounds/acoustic_ulg_medium.caf b/Loop/Resources/Sounds/acoustic_ulg_medium.caf
new file mode 100644
index 000000000..8c205455f
Binary files /dev/null and b/Loop/Resources/Sounds/acoustic_ulg_medium.caf differ
diff --git a/Loop/Resources/Sounds/acoustic_ulg_mild.caf b/Loop/Resources/Sounds/acoustic_ulg_mild.caf
new file mode 100644
index 000000000..e9a4d1df8
Binary files /dev/null and b/Loop/Resources/Sounds/acoustic_ulg_mild.caf differ
diff --git a/Loop/Resources/Sounds/electronic_hg_harsh.caf b/Loop/Resources/Sounds/electronic_hg_harsh.caf
new file mode 100644
index 000000000..abf923b93
Binary files /dev/null and b/Loop/Resources/Sounds/electronic_hg_harsh.caf differ
diff --git a/Loop/Resources/Sounds/electronic_hg_medium.caf b/Loop/Resources/Sounds/electronic_hg_medium.caf
new file mode 100644
index 000000000..c1e363c44
Binary files /dev/null and b/Loop/Resources/Sounds/electronic_hg_medium.caf differ
diff --git a/Loop/Resources/Sounds/electronic_hg_mild.caf b/Loop/Resources/Sounds/electronic_hg_mild.caf
new file mode 100644
index 000000000..58d3b8806
Binary files /dev/null and b/Loop/Resources/Sounds/electronic_hg_mild.caf differ
diff --git a/Loop/Resources/Sounds/electronic_lg_harsh.caf b/Loop/Resources/Sounds/electronic_lg_harsh.caf
new file mode 100644
index 000000000..974f617dc
Binary files /dev/null and b/Loop/Resources/Sounds/electronic_lg_harsh.caf differ
diff --git a/Loop/Resources/Sounds/electronic_lg_medium.caf b/Loop/Resources/Sounds/electronic_lg_medium.caf
new file mode 100644
index 000000000..c59bf497b
Binary files /dev/null and b/Loop/Resources/Sounds/electronic_lg_medium.caf differ
diff --git a/Loop/Resources/Sounds/electronic_lg_mild.caf b/Loop/Resources/Sounds/electronic_lg_mild.caf
new file mode 100644
index 000000000..5f19600d8
Binary files /dev/null and b/Loop/Resources/Sounds/electronic_lg_mild.caf differ
diff --git a/Loop/Resources/Sounds/electronic_lgs_harsh.caf b/Loop/Resources/Sounds/electronic_lgs_harsh.caf
new file mode 100644
index 000000000..4660f5b72
Binary files /dev/null and b/Loop/Resources/Sounds/electronic_lgs_harsh.caf differ
diff --git a/Loop/Resources/Sounds/electronic_lgs_medium.caf b/Loop/Resources/Sounds/electronic_lgs_medium.caf
new file mode 100644
index 000000000..257d7f462
Binary files /dev/null and b/Loop/Resources/Sounds/electronic_lgs_medium.caf differ
diff --git a/Loop/Resources/Sounds/electronic_lgs_mild.caf b/Loop/Resources/Sounds/electronic_lgs_mild.caf
new file mode 100644
index 000000000..7fd98bcda
Binary files /dev/null and b/Loop/Resources/Sounds/electronic_lgs_mild.caf differ
diff --git a/Loop/Resources/Sounds/electronic_ulg_harsh.caf b/Loop/Resources/Sounds/electronic_ulg_harsh.caf
new file mode 100644
index 000000000..aa44a6c6d
Binary files /dev/null and b/Loop/Resources/Sounds/electronic_ulg_harsh.caf differ
diff --git a/Loop/Resources/Sounds/electronic_ulg_medium.caf b/Loop/Resources/Sounds/electronic_ulg_medium.caf
new file mode 100644
index 000000000..193981afa
Binary files /dev/null and b/Loop/Resources/Sounds/electronic_ulg_medium.caf differ
diff --git a/Loop/Resources/Sounds/electronic_ulg_mild.caf b/Loop/Resources/Sounds/electronic_ulg_mild.caf
new file mode 100644
index 000000000..a7854d13b
Binary files /dev/null and b/Loop/Resources/Sounds/electronic_ulg_mild.caf differ
diff --git a/Loop/Resources/Sounds/glass_bell.caf b/Loop/Resources/Sounds/glass_bell.caf
new file mode 100644
index 000000000..20ba3afbd
Binary files /dev/null and b/Loop/Resources/Sounds/glass_bell.caf differ
diff --git a/Loop/Resources/Sounds/hard_saw_wave.caf b/Loop/Resources/Sounds/hard_saw_wave.caf
new file mode 100644
index 000000000..93546fd3a
Binary files /dev/null and b/Loop/Resources/Sounds/hard_saw_wave.caf differ
diff --git a/Loop/Resources/Sounds/harp.caf b/Loop/Resources/Sounds/harp.caf
new file mode 100644
index 000000000..b943bd871
Binary files /dev/null and b/Loop/Resources/Sounds/harp.caf differ
diff --git a/Loop/Resources/Sounds/meow.caf b/Loop/Resources/Sounds/meow.caf
new file mode 100644
index 000000000..d23a03948
Binary files /dev/null and b/Loop/Resources/Sounds/meow.caf differ
diff --git a/Loop/Resources/Sounds/open_can.caf b/Loop/Resources/Sounds/open_can.caf
new file mode 100644
index 000000000..0fc9a798c
Binary files /dev/null and b/Loop/Resources/Sounds/open_can.caf differ
diff --git a/Loop/Resources/Sounds/open_wrapper.caf b/Loop/Resources/Sounds/open_wrapper.caf
new file mode 100644
index 000000000..674a7c2e3
Binary files /dev/null and b/Loop/Resources/Sounds/open_wrapper.caf differ
diff --git a/Loop/Resources/Sounds/saw_wave.caf b/Loop/Resources/Sounds/saw_wave.caf
new file mode 100644
index 000000000..de51c1109
Binary files /dev/null and b/Loop/Resources/Sounds/saw_wave.caf differ
diff --git a/Loop/Resources/Sounds/sine_wave.caf b/Loop/Resources/Sounds/sine_wave.caf
new file mode 100644
index 000000000..f46bae327
Binary files /dev/null and b/Loop/Resources/Sounds/sine_wave.caf differ
diff --git a/Loop/Resources/Sounds/voice_hg_amy.caf b/Loop/Resources/Sounds/voice_hg_amy.caf
new file mode 100644
index 000000000..5544f7daa
Binary files /dev/null and b/Loop/Resources/Sounds/voice_hg_amy.caf differ
diff --git a/Loop/Resources/Sounds/voice_hg_bryce.caf b/Loop/Resources/Sounds/voice_hg_bryce.caf
new file mode 100644
index 000000000..71c8baa6a
Binary files /dev/null and b/Loop/Resources/Sounds/voice_hg_bryce.caf differ
diff --git a/Loop/Resources/Sounds/voice_lg_amy.caf b/Loop/Resources/Sounds/voice_lg_amy.caf
new file mode 100644
index 000000000..d5cbc365b
Binary files /dev/null and b/Loop/Resources/Sounds/voice_lg_amy.caf differ
diff --git a/Loop/Resources/Sounds/voice_lg_bryce.caf b/Loop/Resources/Sounds/voice_lg_bryce.caf
new file mode 100644
index 000000000..085ccb9d3
Binary files /dev/null and b/Loop/Resources/Sounds/voice_lg_bryce.caf differ
diff --git a/Loop/Resources/Sounds/voice_lgs_amy.caf b/Loop/Resources/Sounds/voice_lgs_amy.caf
new file mode 100644
index 000000000..abd6dbd63
Binary files /dev/null and b/Loop/Resources/Sounds/voice_lgs_amy.caf differ
diff --git a/Loop/Resources/Sounds/voice_lgs_bryce.caf b/Loop/Resources/Sounds/voice_lgs_bryce.caf
new file mode 100644
index 000000000..26e72f06f
Binary files /dev/null and b/Loop/Resources/Sounds/voice_lgs_bryce.caf differ
diff --git a/Loop/Resources/Sounds/voice_ulg_amy.caf b/Loop/Resources/Sounds/voice_ulg_amy.caf
new file mode 100644
index 000000000..8b2291883
Binary files /dev/null and b/Loop/Resources/Sounds/voice_ulg_amy.caf differ
diff --git a/Loop/Resources/Sounds/voice_ulg_bryce.caf b/Loop/Resources/Sounds/voice_ulg_bryce.caf
new file mode 100644
index 000000000..4a33c642f
Binary files /dev/null and b/Loop/Resources/Sounds/voice_ulg_bryce.caf differ
diff --git a/Loop/Views/GlucoseAlertSettingsView.swift b/Loop/Views/GlucoseAlertSettingsView.swift
index bb04c49e9..27f800355 100644
--- a/Loop/Views/GlucoseAlertSettingsView.swift
+++ b/Loop/Views/GlucoseAlertSettingsView.swift
@@ -402,8 +402,9 @@ struct GlucoseAlertDetailView: View {
let horizon: HorizonBinding?
let infoNotice: String?
var delay: DelayBinding? = nil
- var showsTestButton: Bool = false
var soundBinding: Binding? = nil
+ /// Which alarm this is, so the picker can lead with the sets written for it.
+ var soundKind: GlucoseAlarmKind? = nil
var playsThroughSilent: Binding? = nil
struct SnoozeBinding {
@@ -456,10 +457,10 @@ struct GlucoseAlertDetailView: View {
Text("Fires once when Loop's forecast first dips below the level inside this horizon. Re-arms when the forecast clears.")
}
}
- if let soundBinding {
+ if let soundBinding, let soundKind {
Section {
NavigationLink {
- SoundPickerView(selection: soundBinding)
+ SoundPickerView(kind: soundKind, selection: soundBinding)
} label: {
HStack {
Text("Sound")
@@ -478,23 +479,8 @@ struct GlucoseAlertDetailView: View {
Text("The sound played when this alarm fires.")
}
}
- }
- }
- if showsTestButton, FeatureFlags.allowDebugFeatures {
- Section {
- if manager.pendingTestUrgentLow {
- HStack {
- Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
- Text("Test armed — next CGM reading will fire")
- Spacer()
- Button("Cancel") { manager.cancelTestUrgentLow() }.foregroundStyle(.red)
- }
- } else {
- Button("Arm test alert") { manager.armTestUrgentLowOnNextReading() }
- }
- } footer: {
- Text("Arming substitutes a below-threshold value in place of the next real CGM reading, so the alert fires from whatever app state Loop is in at that moment.")
+ ArmTestSection(kind: soundKind, manager: manager)
}
}
}
@@ -503,6 +489,61 @@ struct GlucoseAlertDetailView: View {
}
}
+/// Sends one real alert on demand, so the alarm can be checked end to end
+/// against whatever the phone is actually set to — ringer level, silent
+/// switch, a Focus, Do Not Disturb, the Critical Alerts permission.
+///
+/// Arming rather than firing on the spot is the point: the alert goes out
+/// alongside the next reading, which leaves time to lock the phone, put it in
+/// a pocket or turn a Focus on first. Firing from the settings screen would
+/// only ever prove that a foreground alert works.
+private struct ArmTestSection: View {
+ let kind: GlucoseAlarmKind
+ @ObservedObject var manager: GlucoseAlertManager
+
+ private var isArmed: Bool { manager.armedTest == kind }
+
+ var body: some View {
+ Section {
+ if isArmed {
+ HStack {
+ Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
+ Text(armedLabel)
+ Spacer()
+ Button("Cancel") { manager.cancelArmedTest() }.foregroundStyle(.red)
+ }
+ } else {
+ Button("Test This Alert") { manager.armTest(for: kind) }
+ }
+ } footer: {
+ Text(explanation)
+ }
+ }
+
+ private var armedLabel: String {
+ kind == .predictedLow
+ ? NSLocalizedString("Armed — fires on the next loop cycle",
+ comment: "Status of an armed predicted-low alert test")
+ : NSLocalizedString("Armed — fires on the next sensor reading",
+ comment: "Status of an armed glucose alert test")
+ }
+
+ private var explanation: String {
+ let lead = kind == .predictedLow
+ ? NSLocalizedString("Sends this alert once, on the next completed loop cycle.",
+ comment: "Footer of the predicted-low alert test control, when it fires")
+ : NSLocalizedString("Sends this alert once, with the next sensor reading.",
+ comment: "Footer of the glucose alert test control, when it fires")
+ let purpose = NSLocalizedString(
+ "Lock your phone, turn on a Focus or turn your ringer down first, then see whether the alert still reaches you.",
+ comment: "Footer of the glucose alert test control, what it is for"
+ )
+ let reassurance = NSLocalizedString("Your glucose data and your real alerts aren't affected.",
+ comment: "Footer of the glucose alert test control, reassurance")
+ return [lead, purpose, reassurance].joined(separator: " ")
+ }
+}
+
extension GlucoseAlertDetailView {
static func low(manager: GlucoseAlertManager, profileID: UUID, detail: AlertProfileDetailView) -> some View {
GlucoseAlertDetailView(
@@ -512,7 +553,7 @@ extension GlucoseAlertDetailView {
enabled: detail.cfgBind(\.lowEnabled),
snooze: .init(enabled: detail.cfgBind(\.lowSnoozeEnabled), interval: detail.cfgBind(\.lowRepeatInterval)),
horizon: nil, infoNotice: nil,
- soundBinding: Binding(get: { manager.lowSound }, set: { manager.lowSound = $0 }),
+ soundBinding: Binding(get: { manager.lowSound }, set: { manager.lowSound = $0 }), soundKind: .low,
playsThroughSilent: Binding(get: { manager.lowPlaysThroughSilent }, set: { manager.lowPlaysThroughSilent = $0 })
)
}
@@ -525,8 +566,8 @@ extension GlucoseAlertDetailView {
description: "Alerts you when your sensor reading is at or below this level. Repeats every 5 min while still low.",
levelLabel: "Level", level: detail.cfgBind(\.urgentLowThresholdMgDL), levelRange: 50...70,
enabled: detail.cfgBind(\.urgentLowEnabled),
- snooze: nil, horizon: nil, infoNotice: notice, showsTestButton: true,
- soundBinding: Binding(get: { manager.urgentLowSound }, set: { manager.urgentLowSound = $0 })
+ snooze: nil, horizon: nil, infoNotice: notice,
+ soundBinding: Binding(get: { manager.urgentLowSound }, set: { manager.urgentLowSound = $0 }), soundKind: .urgentLow
)
}
@@ -539,7 +580,7 @@ extension GlucoseAlertDetailView {
snooze: .init(enabled: detail.cfgBind(\.highSnoozeEnabled), interval: detail.cfgBind(\.highRepeatInterval)),
horizon: nil, infoNotice: nil,
delay: .init(enabled: detail.cfgBind(\.highDelayEnabled), interval: detail.cfgBind(\.highDelay)),
- soundBinding: Binding(get: { manager.highSound }, set: { manager.highSound = $0 }),
+ soundBinding: Binding(get: { manager.highSound }, set: { manager.highSound = $0 }), soundKind: .high,
playsThroughSilent: Binding(get: { manager.highPlaysThroughSilent }, set: { manager.highPlaysThroughSilent = $0 })
)
}
@@ -551,7 +592,7 @@ extension GlucoseAlertDetailView {
levelLabel: "Level", level: detail.cfgBind(\.predictedLowThresholdMgDL), levelRange: 55...80,
enabled: detail.cfgBind(\.predictedLowEnabled),
snooze: nil, horizon: .init(value: detail.cfgBind(\.predictedLowHorizon)), infoNotice: nil,
- soundBinding: Binding(get: { manager.predictedLowSound }, set: { manager.predictedLowSound = $0 }),
+ soundBinding: Binding(get: { manager.predictedLowSound }, set: { manager.predictedLowSound = $0 }), soundKind: .predictedLow,
playsThroughSilent: Binding(get: { manager.predictedLowPlaysThroughSilent }, set: { manager.predictedLowPlaysThroughSilent = $0 })
)
}
@@ -650,31 +691,230 @@ private struct HorizonRow: View {
// MARK: - Sound picker
+/// Leads with the themed sets written for this alarm and tucks the older
+/// generic tones under a collapsible, so the recommended choices are the
+/// first thing on screen.
private struct SoundPickerView: View {
+ let kind: GlucoseAlarmKind
@Binding var selection: String
@StateObject private var preview = AlarmSoundPreviewPlayer()
+ /// Start expanded when the current pick lives in there, so the selection
+ /// is never hidden behind a closed group.
+ @State private var showsOtherSounds: Bool
+
+ init(kind: GlucoseAlarmKind, selection: Binding) {
+ self.kind = kind
+ _selection = selection
+ _showsOtherSounds = State(
+ initialValue: AlarmSoundCatalog.entry(forFilename: selection.wrappedValue)?.isGeneric ?? false
+ )
+ }
var body: some View {
- List {
- ForEach(AlarmSoundCatalog.all) { sound in
- Button {
- selection = sound.filename
- preview.play(soundNamed: sound.filename)
- } label: {
- HStack {
- Text(sound.displayName).foregroundStyle(.primary)
- Spacer()
- if sound.filename == selection {
- Image(systemName: "checkmark").foregroundStyle(.tint).fontWeight(.semibold)
+ ScrollViewReader { proxy in
+ List {
+ Section {
+ ForEach(AlarmSoundCatalog.themedEntries(for: kind)) { entry in
+ SoundPickerRow(entry: entry, selection: $selection, preview: preview)
+ .id(entry.id)
+ }
+ } footer: {
+ Text(footer)
+ }
+
+ Section {
+ // Not a DisclosureGroup: its chevron points the way a
+ // NavigationLink's does, so the row reads as a push rather
+ // than as something that opens in place. Down for "more
+ // below", flipping up once it is open.
+ Button {
+ withAnimation { showsOtherSounds.toggle() }
+ } label: {
+ HStack {
+ Text("Other Sounds").foregroundStyle(.primary)
+ Spacer()
+ Text("\(AlarmSoundCatalog.genericTones.count)")
+ .foregroundStyle(.secondary)
+ Image(systemName: "chevron.down")
+ .font(.footnote.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .rotationEffect(.degrees(showsOtherSounds ? 180 : 0))
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityHint(showsOtherSounds ? "Collapse" : "Expand")
+
+ if showsOtherSounds {
+ ForEach(AlarmSoundCatalog.genericTones) { entry in
+ SoundPickerRow(entry: entry, selection: $selection, preview: preview)
+ .id(entry.id)
}
}
}
}
+ .onAppear {
+ // Opening the group isn't enough on its own — a pick near the
+ // bottom of 26 tones is still off screen. Land on it.
+ guard let entry = AlarmSoundCatalog.entry(forFilename: selection), entry.isGeneric else { return }
+ DispatchQueue.main.async { proxy.scrollTo(entry.id, anchor: .center) }
+ }
}
- .navigationTitle("Sound")
+ .navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.onDisappear { preview.stop() }
}
+
+ /// Names the alarm being edited: four screens otherwise titled "Sound".
+ private var title: String {
+ String(
+ format: NSLocalizedString("%1$@ Sound", comment: "Title of the alarm sound picker (1: alarm name)"),
+ kind.displayName
+ )
+ }
+
+ /// Describes whichever control the selected set actually shows.
+ private var footer: String {
+ let lead = NSLocalizedString("These sets are written for this alarm.",
+ comment: "Footer of the themed alarm sound list")
+ switch AlarmSoundCatalog.entry(forFilename: selection)?.selector {
+ case .urgency:
+ return lead + " " + NSLocalizedString("Urgency picks a different recording, not the same one louder.",
+ comment: "Footer of the themed alarm sound list, urgency slider shown")
+ case .voice:
+ return lead + " " + NSLocalizedString("Voice picks who reads the alert out.",
+ comment: "Footer of the themed alarm sound list, voice picker shown")
+ default:
+ // A generic tone, or a file no longer in the catalog: no control.
+ return lead
+ }
+ }
+}
+
+private struct SoundPickerRow: View {
+ let entry: AlarmSoundEntry
+ @Binding var selection: String
+ @ObservedObject var preview: AlarmSoundPreviewPlayer
+
+ private var isSelected: Bool { entry.filenames.contains(selection) }
+
+ /// The file this row stands for: the picked variant while selected,
+ /// otherwise the entry's default.
+ private var filename: String {
+ isSelected ? selection : entry.defaultVariant.filename
+ }
+
+ private var variant: AlarmSound {
+ entry.variant(forFilename: filename) ?? entry.defaultVariant
+ }
+
+ private var isPlaying: Bool { preview.playing == filename }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ row
+ if isSelected, entry.selector != .none {
+ variantControl
+ }
+ }
+ .animation(.easeInOut(duration: 0.15), value: isSelected)
+ }
+
+ private var row: some View {
+ HStack(spacing: 12) {
+ Button {
+ select(variant)
+ } label: {
+ HStack(spacing: 12) {
+ Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
+ .foregroundStyle(isSelected ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
+ .frame(width: 20)
+ Text(entry.displayName).foregroundStyle(.primary)
+ Spacer()
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
+
+ Button {
+ preview.toggle(soundNamed: filename)
+ } label: {
+ Image(systemName: isPlaying ? "stop.circle.fill" : "play.circle.fill")
+ .font(.title3)
+ .foregroundStyle(.tint)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(isPlaying ? "Stop preview" : "Play preview")
+ }
+ }
+
+ @ViewBuilder private var variantControl: some View {
+ switch entry.selector {
+ case .urgency: urgencySlider
+ case .voice: voicePicker
+ case .none: EmptyView()
+ }
+ }
+
+ /// Discrete Mild / Medium / Harsh. Changing it auditions the new level —
+ /// the point is hearing how the character changes.
+ private var urgencySlider: some View {
+ VStack(alignment: .leading, spacing: 2) {
+ HStack {
+ Text("Urgency Level")
+ Spacer()
+ Text(variant.displayName).foregroundStyle(.secondary)
+ }
+ .font(.footnote)
+
+ Slider(
+ value: Binding(
+ get: { Double(entry.variants.firstIndex(of: variant) ?? 0) },
+ set: { raw in
+ let index = min(max(Int(raw.rounded()), 0), entry.variants.count - 1)
+ select(entry.variants[index])
+ }
+ ),
+ in: 0...Double(max(entry.variants.count - 1, 1)),
+ step: 1
+ )
+ .padding(.vertical, 4)
+ .accessibilityLabel("Urgency Level")
+ .accessibilityValue(variant.displayName)
+
+ HStack {
+ ForEach(Array(entry.variants.enumerated()), id: \.element.id) { index, item in
+ if index > 0 { Spacer() }
+ Text(item.displayName).font(.caption2).foregroundStyle(.secondary)
+ }
+ }
+ .accessibilityHidden(true)
+ }
+ .padding(.leading, 32)
+ }
+
+ /// No label above it: the row this sits under is already titled "Voice".
+ private var voicePicker: some View {
+ Picker("Voice", selection: Binding(
+ get: { variant.id },
+ set: { id in
+ guard let match = entry.variants.first(where: { $0.id == id }) else { return }
+ select(match)
+ }
+ )) {
+ ForEach(entry.variants) { Text($0.displayName).tag($0.id) }
+ }
+ .labelsHidden()
+ .pickerStyle(.segmented)
+ .padding(.leading, 32)
+ }
+
+ /// Selecting always auditions: picking a sound you can't hear isn't a choice.
+ private func select(_ sound: AlarmSound) {
+ selection = sound.filename
+ preview.play(soundNamed: sound.filename)
+ }
}
/// Plays a short preview of a bundled `.caf` alarm sound. Uses the
@@ -682,8 +922,18 @@ private struct SoundPickerView: View {
/// switch on, matching how the alarms themselves behave.
@MainActor
private final class AlarmSoundPreviewPlayer: ObservableObject {
+ /// Filename currently sounding, so a row can show stop instead of play.
+ @Published private(set) var playing: String?
private var player: AVAudioPlayer?
+ func toggle(soundNamed soundName: String) {
+ if playing == soundName {
+ stop()
+ } else {
+ play(soundNamed: soundName)
+ }
+ }
+
func play(soundNamed soundName: String) {
stop()
let resource = (soundName as NSString).deletingPathExtension
@@ -695,9 +945,11 @@ private final class AlarmSoundPreviewPlayer: ObservableObject {
try session.setActive(true, options: [])
let p = try AVAudioPlayer(contentsOf: url)
p.volume = 1.0
+ p.delegate = previewDelegate
p.prepareToPlay()
- p.play()
+ guard p.play() else { return }
player = p
+ playing = soundName
} catch {
// Preview is best-effort; silently ignore failures.
}
@@ -706,6 +958,29 @@ private final class AlarmSoundPreviewPlayer: ObservableObject {
func stop() {
player?.stop()
player = nil
+ playing = nil
try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
}
+
+ private lazy var previewDelegate: PreviewDelegate = {
+ let delegate = PreviewDelegate()
+ // Which player finished matters: the hop to the main actor leaves a
+ // tick in which a new preview can already have started, and that one
+ // must not be stopped by its predecessor finishing.
+ delegate.onFinish = { [weak self] finished in
+ guard let self, self.player === finished else { return }
+ self.stop()
+ }
+ return delegate
+ }()
+}
+
+/// Clears the play state once a preview runs out, so the row flips back from
+/// stop to play without the user tapping anything.
+private final class PreviewDelegate: NSObject, AVAudioPlayerDelegate {
+ var onFinish: ((AVAudioPlayer) -> Void)?
+
+ func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully _: Bool) {
+ Task { @MainActor in self.onFinish?(player) }
+ }
}
diff --git a/LoopTests/Managers/AlarmSoundCatalogTests.swift b/LoopTests/Managers/AlarmSoundCatalogTests.swift
new file mode 100644
index 000000000..8e91d0399
--- /dev/null
+++ b/LoopTests/Managers/AlarmSoundCatalogTests.swift
@@ -0,0 +1,137 @@
+//
+// AlarmSoundCatalogTests.swift
+// LoopTests
+//
+// Copyright © 2026 LoopKit Authors. All rights reserved.
+//
+
+import XCTest
+@testable import Loop
+
+final class AlarmSoundCatalogTests: XCTestCase {
+
+ /// The catalog is only a list of names; a typo or a file left out of the
+ /// target makes the alarm fall silent at runtime. Tests are hosted in
+ /// Loop.app, so the app bundle is what ships.
+ func testEveryCatalogFileIsBundled() {
+ for filename in AlarmSoundCatalog.allFilenames {
+ let resource = (filename as NSString).deletingPathExtension
+ let ext = (filename as NSString).pathExtension
+ XCTAssertNotNil(
+ Bundle.main.url(forResource: resource, withExtension: ext),
+ "\(filename) is in the catalog but not bundled"
+ )
+ }
+ }
+
+ func testFilenamesAreUnique() {
+ let all = AlarmSoundCatalog.allFilenames
+ XCTAssertEqual(Set(all).count, all.count)
+ }
+
+ func testEveryAlarmOffersAcousticElectronicAndVoice() {
+ for kind in GlucoseAlarmKind.allCases {
+ let ids = AlarmSoundCatalog.themedEntries(for: kind).map { $0.id }
+ XCTAssertEqual(ids.count, 3, "\(kind) should offer three themed sets")
+ XCTAssertTrue(ids.contains { $0.hasPrefix("acoustic.") })
+ XCTAssertTrue(ids.contains { $0.hasPrefix("electronic.") })
+ XCTAssertTrue(ids.contains { $0.hasPrefix("voice.") })
+ }
+ }
+
+ /// A themed set is written for one alarm, so it must not surface on another.
+ func testThemedSetsAreScopedToOneAlarm() {
+ for kind in GlucoseAlarmKind.allCases {
+ for entry in AlarmSoundCatalog.themedEntries(for: kind) {
+ XCTAssertEqual(entry.kinds, [kind])
+ }
+ }
+ }
+
+ func testGenericTonesAreOfferedEverywhere() {
+ for entry in AlarmSoundCatalog.genericTones {
+ XCTAssertTrue(entry.isGeneric)
+ XCTAssertEqual(entry.variants.count, 1)
+ XCTAssertEqual(entry.selector, .none)
+ }
+ // The legacy tones stay selectable so an upgrade never loses a pick.
+ let filenames = AlarmSoundCatalog.genericTones.map { $0.id }
+ for legacy in ["urgent_low.caf", "critical.caf", "alarm.caf", "simple.caf", "chime.caf", "mood_synth.caf"] {
+ XCTAssertTrue(filenames.contains(legacy), "\(legacy) should remain selectable")
+ }
+ }
+
+ func testDefaultsAreAcousticMedium() {
+ for kind in GlucoseAlarmKind.allCases {
+ XCTAssertTrue(
+ AlarmSoundCatalog.defaultFilename(for: kind).hasPrefix("acoustic_"),
+ "\(kind) should default to the Acoustic set"
+ )
+ XCTAssertTrue(AlarmSoundCatalog.defaultFilename(for: kind).hasSuffix("_medium.caf"))
+ }
+ XCTAssertEqual(GlucoseAlertManager.defaultUrgentLowSound, "acoustic_ulg_medium.caf")
+ XCTAssertEqual(GlucoseAlertManager.defaultLowSound, "acoustic_lg_medium.caf")
+ XCTAssertEqual(GlucoseAlertManager.defaultPredictedLowSound, "acoustic_lgs_medium.caf")
+ XCTAssertEqual(GlucoseAlertManager.defaultHighSound, "acoustic_hg_medium.caf")
+ }
+
+ /// Only the filename is persisted, so every filename has to lead back to
+ /// the row and variant that produced it.
+ func testEveryFilenameResolvesBackToItsEntry() {
+ for entry in AlarmSoundCatalog.entries {
+ for variant in entry.variants {
+ XCTAssertEqual(AlarmSoundCatalog.entry(forFilename: variant.filename)?.id, entry.id)
+ XCTAssertEqual(entry.variant(forFilename: variant.filename), variant)
+ }
+ }
+ }
+
+ func testDisplayNameNamesTheVariantOnlyForThemedSets() {
+ XCTAssertEqual(AlarmSoundCatalog.displayName(for: "acoustic_ulg_medium.caf"), "Acoustic · Medium")
+ XCTAssertEqual(AlarmSoundCatalog.displayName(for: "voice_hg_amy.caf"), "Voice · Amy")
+ XCTAssertEqual(AlarmSoundCatalog.displayName(for: "chime.caf"), "Chime")
+ // An unknown file still shows something rather than an empty row.
+ XCTAssertEqual(AlarmSoundCatalog.displayName(for: "gone.caf"), "gone.caf")
+ }
+
+ /// The in-app critical fallback used to stop at a flat 6s, which cut the
+ /// 10.8s urgent-low alarms off at just over half. The burst now runs whole
+ /// passes of whatever was picked.
+ func testCriticalBurstPlaysWholePassesOfTheSound() {
+ // A long alarm plays through once rather than being cut mid-phrase.
+ XCTAssertEqual(CriticalAlertAudioPlayer.burstDuration(forSoundOfDuration: 10.8), 10.8, accuracy: 0.001)
+ // A short tone still repeats up to the floor, landing on a whole pass.
+ XCTAssertEqual(CriticalAlertAudioPlayer.burstDuration(forSoundOfDuration: 0.84), 6.72, accuracy: 0.001)
+ // Exactly the floor is one pass.
+ XCTAssertEqual(CriticalAlertAudioPlayer.burstDuration(forSoundOfDuration: 6), 6, accuracy: 0.001)
+ // Never shorter than the floor...
+ for duration in stride(from: 0.1, through: 12.0, by: 0.1) {
+ XCTAssertGreaterThanOrEqual(
+ CriticalAlertAudioPlayer.burstDuration(forSoundOfDuration: duration),
+ min(CriticalAlertAudioPlayer.minBurstDuration, duration) - 0.001,
+ "\(duration)s"
+ )
+ }
+ // ...and never a runaway siren.
+ XCTAssertEqual(CriticalAlertAudioPlayer.burstDuration(forSoundOfDuration: 45),
+ CriticalAlertAudioPlayer.maxBurstDuration, accuracy: 0.001)
+ // A zero-length (unreadable) file falls back to the floor rather than 0.
+ XCTAssertEqual(CriticalAlertAudioPlayer.burstDuration(forSoundOfDuration: 0),
+ CriticalAlertAudioPlayer.minBurstDuration, accuracy: 0.001)
+ }
+
+ /// Every alarm names itself on screen — the sound picker is titled with it.
+ func testEveryAlarmHasADistinctName() {
+ let names = GlucoseAlarmKind.allCases.map { $0.displayName }
+ XCTAssertEqual(Set(names).count, names.count)
+ XCTAssertFalse(names.contains { $0.isEmpty })
+ }
+
+ func testThemedEntryDefaultsToTheMediumVariant() {
+ for kind in GlucoseAlarmKind.allCases {
+ for entry in AlarmSoundCatalog.themedEntries(for: kind) where entry.selector == .urgency {
+ XCTAssertEqual(entry.defaultVariant.id, AlarmUrgencyLevel.medium.rawValue)
+ }
+ }
+ }
+}
diff --git a/LoopTests/Managers/GlucoseAlertManagerTests.swift b/LoopTests/Managers/GlucoseAlertManagerTests.swift
index 9dca1d1a7..b88d197e9 100644
--- a/LoopTests/Managers/GlucoseAlertManagerTests.swift
+++ b/LoopTests/Managers/GlucoseAlertManagerTests.swift
@@ -6,6 +6,7 @@
//
import XCTest
+import LoopAlgorithm
import LoopKit
@testable import Loop
@@ -134,4 +135,128 @@ final class GlucoseAlertManagerTests: XCTestCase {
await restarted.evaluate(samples: [sample(200, at: rising)], now: rising)
XCTAssertEqual(issuer.issuedIDs, [GlucoseAlertManager.highAlertIdentifier])
}
+
+ // MARK: - Armed alert tests
+
+ private func predicted(_ mgdl: Double, at date: Date) -> PredictedGlucoseValue {
+ PredictedGlucoseValue(startDate: date, quantity: .glucose(value: mgdl))
+ }
+
+ /// The point of arming is that the alert arrives later, from whatever state
+ /// Loop is in — so it waits for a reading rather than firing on the spot.
+ func testArmingDoesNotFireImmediately() async {
+ manager.armTest(for: .low)
+ XCTAssertEqual(manager.armedTest, .low)
+ XCTAssertEqual(issuer.issued, [])
+ }
+
+ func testArmedTestFiresOnTheNextReading() async {
+ manager.armTest(for: .low)
+ let now = Date()
+ await manager.evaluate(samples: [sample(120, at: now)], now: now)
+ XCTAssertEqual(issuer.issuedIDs, [GlucoseAlarmKind.low.testAlertIdentifier])
+ XCTAssertNil(manager.armedTest, "The test is one-shot")
+ }
+
+ /// It rides along with the reading; it must not stand in for it. An armed
+ /// test while glucose is genuinely urgent-low has to leave the real alert
+ /// alone.
+ func testArmedTestLeavesTheRealEvaluationAlone() async {
+ manager.armTest(for: .high)
+ let now = Date()
+ await manager.evaluate(samples: [sample(50, at: now)], now: now)
+ XCTAssertEqual(issuer.issuedIDs,
+ [GlucoseAlertManager.urgentLowAlertIdentifier, GlucoseAlarmKind.high.testAlertIdentifier])
+ }
+
+ /// Arming one alarm must not fire another.
+ func testArmedTestFiresOnlyTheArmedAlarm() async {
+ manager.armTest(for: .urgentLow)
+ let now = Date()
+ await manager.evaluate(samples: [sample(120, at: now)], now: now)
+ XCTAssertEqual(issuer.issuedIDs, [GlucoseAlarmKind.urgentLow.testAlertIdentifier])
+ }
+
+ func testCancellingAnArmedTestStopsIt() async {
+ manager.armTest(for: .low)
+ manager.cancelArmedTest()
+ XCTAssertNil(manager.armedTest)
+ let now = Date()
+ await manager.evaluate(samples: [sample(120, at: now)], now: now)
+ XCTAssertEqual(issuer.issuedIDs, [])
+ }
+
+ /// Predicted Low has no reading of its own to ride along with, so a glucose
+ /// batch must leave its armed test alone and the next loop cycle must fire it.
+ func testPredictedLowTestWaitsForALoopCycle() async {
+ manager.armTest(for: .predictedLow)
+ let now = Date()
+ await manager.evaluate(samples: [sample(120, at: now)], now: now)
+ XCTAssertEqual(issuer.issuedIDs, [])
+ XCTAssertEqual(manager.armedTest, .predictedLow)
+
+ // A forecast that stays well above the threshold: no real alert.
+ await manager.evaluatePredictedGlucose([predicted(140, at: now.addingTimeInterval(15 * 60))], now: now)
+ XCTAssertEqual(issuer.issuedIDs, [GlucoseAlarmKind.predictedLow.testAlertIdentifier])
+ XCTAssertNil(manager.armedTest)
+ }
+
+ /// The test is only worth anything if it asks for delivery on the same
+ /// terms as the real alarm: same sound, same interruption level.
+ func testArmedTestCarriesTheAlarmsSoundAndInterruptionLevel() async {
+ manager.urgentLowSound = "chime.caf"
+ manager.armTest(for: .urgentLow)
+ let now = Date()
+ await manager.evaluate(samples: [sample(120, at: now)], now: now)
+ let alert = issuer.issued.first
+ XCTAssertEqual(alert?.sound?.filename, "chime.caf")
+ XCTAssertEqual(alert?.interruptionLevel, .critical)
+ XCTAssertEqual(alert?.trigger, .immediate)
+ }
+
+ func testNonUrgentArmedTestsAreTimeSensitive() {
+ for kind in [GlucoseAlarmKind.low, .high, .predictedLow] {
+ XCTAssertEqual(kind.interruptionLevel, .timeSensitive, "\(kind)")
+ }
+ XCTAssertEqual(GlucoseAlarmKind.urgentLow.interruptionLevel, .critical)
+ }
+
+ /// A test identifier has to be distinct from the real one — that is what
+ /// keeps a test out of the real alarm's episode state and retraction — but
+ /// still has to resolve to the same alarm's sound and silent-mode setting.
+ func testTestIdentifiersAreDistinctButResolveToTheSameAlarm() {
+ manager.lowSound = "harp.caf"
+ manager.lowPlaysThroughSilent = true
+ for kind in GlucoseAlarmKind.allCases {
+ XCTAssertNotEqual(kind.testAlertIdentifier, kind.alertIdentifier)
+ XCTAssertEqual(GlucoseAlarmKind.kind(forAlertIdentifier: kind.testAlertIdentifier), kind)
+ XCTAssertEqual(GlucoseAlarmKind.kind(forAlertIdentifier: kind.alertIdentifier), kind)
+ XCTAssertEqual(manager.configuredSoundName(for: kind.testAlertIdentifier),
+ manager.configuredSoundName(for: kind.alertIdentifier))
+ XCTAssertEqual(manager.playsThroughSilent(for: kind.testAlertIdentifier),
+ manager.playsThroughSilent(for: kind.alertIdentifier))
+ }
+ XCTAssertEqual(manager.configuredSoundName(for: GlucoseAlarmKind.low.testAlertIdentifier), "harp.caf")
+ XCTAssertTrue(manager.playsThroughSilent(for: GlucoseAlarmKind.low.testAlertIdentifier))
+ }
+
+ /// iOS can terminate a backgrounded Loop before the next reading lands; a
+ /// test that quietly evaporates looks exactly like a broken alarm.
+ func testArmedTestSurvivesRelaunch() async {
+ manager.armTest(for: .high)
+ let restarted = relaunch()
+ XCTAssertEqual(restarted.armedTest, .high)
+
+ let now = Date()
+ await restarted.evaluate(samples: [sample(120, at: now)], now: now)
+ XCTAssertEqual(issuer.issuedIDs, [GlucoseAlarmKind.high.testAlertIdentifier])
+
+ // And it is cleared, so it can't fire again on the launch after that.
+ XCTAssertNil(relaunch().armedTest)
+ }
+
+ func testUnknownIdentifierHasNoAlarm() {
+ XCTAssertNil(GlucoseAlarmKind.kind(forAlertIdentifier: "something.else"))
+ XCTAssertFalse(manager.playsThroughSilent(for: "something.else"))
+ }
}