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")) + } }