Skip to content

Commit b2f4bbf

Browse files
OrinksOmX
andauthored
refactor(sounds): simplify alert sounds by severity (#679)
## Summary - simplify alert sound matching to severity-first events: extreme, severe, moderate, and minor - keep alert/notify fallback behavior and preserve hidden legacy sound-pack and muted-event keys for compatibility - reduce the visible sound-pack manager and wizard event catalog to the useful app, lifecycle, and alert-severity events ## Verification - pytest tests/test_settings_dialog_audio_events.py tests/test_alert_sound_mapper.py tests/test_sound_player.py::TestGetSoundEntry::test_get_sound_entry_for_candidates_falls_back_within_pack tests/test_sound_player.py::TestUserLevelMute tests/test_soundpack_event_catalog.py -q - pytest tests/test_alert_notification_system.py tests/test_toasted_windows_notifier.py::TestToastedWindowsNotifierSend::test_send_uses_sound_candidates_when_provided -q - ruff check targeted files - ruff format --check targeted files - pyright - git diff --check ## Manual testing - Not run: manual audio playback through the desktop UI Co-authored-by: OmX <omx@oh-my-codex.dev>
1 parent 831ab7a commit b2f4bbf

12 files changed

Lines changed: 360 additions & 623 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ All notable changes to this project will be documented in this file.
1414

1515
### Changed
1616
- Linux nightly and release downloads now ship as `.tar.gz` tarballs instead of ZIP files.
17+
- Alert sounds and sound-pack creation now focus on alert severity instead of a long list of specific alert names, while existing packs can keep their older mappings.
1718

1819
### Fixed
1920
- Saving Settings no longer waits on the Windows startup shortcut check unless you actually change the launch-at-startup checkbox.

docs/SOUND_PACK_SYSTEM.md

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -82,28 +82,26 @@ Both formats can be mixed - inline volume takes precedence if specified.
8282
- `notify` - General notifications
8383
- `error` - Error conditions and failures
8484
- `success` - Successful operations
85+
- `data_updated` - Weather refresh completed
86+
- `fetch_error` - Weather refresh failed
87+
- `discussion_update` - Forecast discussion updated
88+
- `severe_risk` - Severe weather risk changed
8589
- `startup` - Application startup sound
8690
- `exit` - Application exit sound
8791

88-
### Weather-Specific Sound Events
89-
90-
AccessiWeather supports specific sound mappings for different types of weather alerts:
91-
92-
- `tornado_warning` - Tornado warnings (highest priority)
93-
- `thunderstorm_warning` - Severe thunderstorm warnings
94-
- `flood_warning` - Flood warnings
95-
- `heat_advisory` - Heat advisories and excessive heat warnings
96-
- `winter_storm_warning` - Winter storm warnings
97-
- `hurricane_warning` - Hurricane warnings
98-
- `wind_warning` - High wind warnings
99-
- `fire_warning` - Fire weather warnings
100-
- `air_quality_alert` - Air quality alerts
101-
- `fog_advisory` - Dense fog advisories
102-
- `ice_warning` - Ice storm warnings
103-
- `snow_warning` - Heavy snow warnings
104-
- `dust_warning` - Dust storm warnings
105-
- `warning` - Generic severe weather warnings
106-
- `watch` - Generic weather watches
92+
### Alert Severity Sound Events
93+
94+
AccessiWeather maps alert notifications by severity first, then falls back to
95+
`alert` and `notify` when a pack does not provide the severity key.
96+
97+
- `extreme` - Extreme severity alerts
98+
- `severe` - Severe severity alerts
99+
- `moderate` - Moderate severity alerts
100+
- `minor` - Minor severity alerts
101+
102+
Older sound packs can keep specific keys such as `tornado_warning`, `warning`,
103+
or `watch`; AccessiWeather still tolerates those mappings for compatibility,
104+
but new packs should use the compact severity keys above.
107105

108106
## Built-in Sound Packs
109107

src/accessiweather/notifications/alert_sound_mapper.py

Lines changed: 4 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,15 @@
11
"""
22
Alert-to-sound mapping utilities.
33
4-
This module provides a small, dependency-free mapper that determines which
5-
sound "event" key should be used for a given WeatherAlert. It produces an
6-
ordered list of candidate event keys, allowing the sound system to try the
7-
first available one in the currently selected pack, then gracefully fall back
8-
by severity and finally to generic defaults.
4+
This module maps weather alerts to a compact set of sound event keys. Alert
5+
sounds are intentionally severity-first so providers do not need exact event
6+
text to agree before a useful sound can play.
97
"""
108

119
from __future__ import annotations
1210

13-
import re
14-
1511
from ..models import WeatherAlert
1612

17-
# Normalized keys we may support in sound packs.
18-
# Packs remain simple JSON dictionaries of event->filename.
19-
# We don't require all keys; missing keys are skipped with fallback.
20-
KNOWN_ALERT_TYPE_KEYS = [
21-
"warning",
22-
"watch",
23-
"advisory",
24-
"statement",
25-
]
26-
2713
KNOWN_SEVERITY_KEYS = [
2814
"extreme",
2915
"severe",
@@ -35,24 +21,6 @@
3521
GENERIC_FALLBACKS = ["alert", "notify"]
3622

3723

38-
def _contains_token(text: str | None, token: str) -> bool:
39-
if not text:
40-
return False
41-
return re.search(rf"\b{re.escape(token)}\b", text, flags=re.IGNORECASE) is not None
42-
43-
44-
def _extract_alert_type(alert: WeatherAlert) -> str | None:
45-
# Look at event and headline/title for NWS-style type words
46-
for key in KNOWN_ALERT_TYPE_KEYS:
47-
if (
48-
_contains_token(alert.event, key)
49-
or _contains_token(alert.headline, key)
50-
or _contains_token(alert.title, key)
51-
):
52-
return key
53-
return None
54-
55-
5624
def _normalize_severity(sev: str | None) -> str | None:
5725
if not sev:
5826
return None
@@ -69,99 +37,17 @@ def _normalize_severity(sev: str | None) -> str | None:
6937
return alias if alias in KNOWN_SEVERITY_KEYS else None
7038

7139

72-
HAZARD_KEYWORDS = {
73-
# core
74-
"flood": ["flood"],
75-
"tornado": ["tornado"],
76-
"heat": ["heat", "excessive heat"],
77-
"wind": ["wind", "high wind"],
78-
"winter": ["winter", "winter storm"],
79-
"snow": ["snow", "heavy snow"],
80-
"ice": ["ice", "freezing rain", "freezing drizzle"],
81-
"thunderstorm": ["thunderstorm", "severe thunderstorm"],
82-
"hurricane": ["hurricane"],
83-
"fire": ["fire", "red flag"],
84-
"fog": ["fog", "dense fog"],
85-
"dust": ["dust", "blowing dust"],
86-
"air_quality": ["air quality", "smoke"],
87-
}
88-
89-
90-
def _extract_hazard(alert: WeatherAlert) -> str | None:
91-
text = " ".join(
92-
[
93-
getattr(alert, "event", "") or "",
94-
getattr(alert, "headline", "") or "",
95-
getattr(alert, "title", "") or "",
96-
getattr(alert, "description", "") or "",
97-
]
98-
).lower()
99-
for hazard_key, phrases in HAZARD_KEYWORDS.items():
100-
for phrase in phrases:
101-
if phrase in text:
102-
return hazard_key
103-
return None
104-
105-
106-
def _normalize_event_to_key(text: str | None) -> str | None:
107-
"""
108-
Normalize an alert event/title/headline to a pack key.
109-
110-
Example: "Excessive Heat Watch" -> "excessive_heat_watch"
111-
"""
112-
if not text:
113-
return None
114-
import re as _re
115-
116-
s = text.strip().lower()
117-
# Replace any non-alphanumeric with underscores
118-
s = _re.sub(r"[^a-z0-9]+", "_", s)
119-
# Collapse multiple underscores
120-
s = _re.sub(r"_+", "_", s)
121-
# Trim leading/trailing underscores
122-
s = s.strip("_")
123-
return s or None
124-
125-
12640
def get_candidate_sound_events(alert: WeatherAlert) -> list[str]:
12741
"""
12842
Return an ordered list of candidate sound event keys for an alert.
12943
13044
Order of preference:
131-
- Exact normalized event key from alert.event (e.g., excessive_heat_watch)
132-
- Hazard + Type (e.g., flood_warning) if both can be detected
133-
- Hazard + Severity (e.g., heat_extreme) if detected
134-
- Hazard only (e.g., flood, heat)
135-
- Specific alert type (warning/watch/advisory/statement)
136-
- Severity level (extreme/severe/moderate/minor)
45+
- Severity level (extreme/severe/moderate/minor), including provider aliases
13746
- Generic fallbacks (alert, notify)
13847
"""
13948
candidates: list[str] = []
14049

141-
# Exact normalized event key first
142-
normalized_event = _normalize_event_to_key(getattr(alert, "event", None))
143-
if normalized_event:
144-
candidates.append(normalized_event)
145-
146-
atype = _extract_alert_type(alert)
14750
sev = _normalize_severity(getattr(alert, "severity", None))
148-
hazard = _extract_hazard(alert)
149-
150-
# Hazard combinations next
151-
if hazard and atype:
152-
key = f"{hazard}_{atype}"
153-
if key not in candidates:
154-
candidates.append(key)
155-
if hazard and sev:
156-
key = f"{hazard}_{sev}"
157-
if key not in candidates:
158-
candidates.append(key)
159-
if hazard and hazard not in candidates:
160-
candidates.append(hazard)
161-
162-
# Then type and severity
163-
if atype and atype not in candidates:
164-
candidates.append(atype)
16551
if sev and sev not in candidates:
16652
candidates.append(sev)
16753

src/accessiweather/sound_events.py

Lines changed: 82 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -31,86 +31,92 @@
3131
),
3232
),
3333
(
34-
"Alert severity fallbacks",
35-
"Fallbacks by alert severity.",
34+
"Alert severities",
35+
"Weather alert sounds by severity.",
3636
(
3737
("extreme", "Extreme severity"),
3838
("severe", "Severe severity"),
3939
("moderate", "Moderate severity"),
4040
("minor", "Minor severity"),
4141
),
4242
),
43-
(
44-
"Alert type fallbacks",
45-
"Fallbacks by alert type.",
46-
(
47-
("warning", "Generic warning"),
48-
("watch", "Generic watch"),
49-
("advisory", "Generic advisory"),
50-
("statement", "Generic statement"),
51-
),
52-
),
53-
(
54-
"Alert events",
55-
"Specific alert sounds.",
56-
(
57-
("tornado_warning", "Tornado Warning"),
58-
("tornado_watch", "Tornado Watch"),
59-
("thunderstorm_warning", "Severe Thunderstorm Warning"),
60-
("thunderstorm_watch", "Severe Thunderstorm Watch"),
61-
("flood_warning", "Flood Warning"),
62-
("flood_watch", "Flood Watch"),
63-
("flood_advisory", "Flood Advisory"),
64-
("flash_flood_warning", "Flash Flood Warning"),
65-
("flash_flood_watch", "Flash Flood Watch"),
66-
("coastal_flood_warning", "Coastal Flood Warning"),
67-
("coastal_flood_watch", "Coastal Flood Watch"),
68-
("coastal_flood_advisory", "Coastal Flood Advisory"),
69-
("river_flood_warning", "River Flood Warning"),
70-
("river_flood_watch", "River Flood Watch"),
71-
("excessive_heat_warning", "Excessive Heat Warning"),
72-
("excessive_heat_watch", "Excessive Heat Watch"),
73-
("heat_advisory", "Heat Advisory"),
74-
("winter_storm_warning", "Winter Storm Warning"),
75-
("winter_storm_watch", "Winter Storm Watch"),
76-
("winter_weather_advisory", "Winter Weather Advisory"),
77-
("blizzard_warning", "Blizzard Warning"),
78-
("ice_storm_warning", "Ice Storm Warning"),
79-
("ice_warning", "Generic ice warning"),
80-
("snow_warning", "Generic snow warning"),
81-
("snow_squall_warning", "Snow Squall Warning"),
82-
("freeze_warning", "Freeze Warning"),
83-
("freeze_watch", "Freeze Watch"),
84-
("frost_advisory", "Frost Advisory"),
85-
("extreme_cold_warning", "Extreme Cold Warning"),
86-
("cold_weather_advisory", "Cold Weather Advisory"),
87-
("high_wind_warning", "High Wind Warning"),
88-
("high_wind_watch", "High Wind Watch"),
89-
("wind_advisory", "Wind Advisory"),
90-
("wind_warning", "Generic wind warning"),
91-
("extreme_wind_warning", "Extreme Wind Warning"),
92-
("hurricane_warning", "Hurricane Warning"),
93-
("hurricane_watch", "Hurricane Watch"),
94-
("tropical_storm_warning", "Tropical Storm Warning"),
95-
("tropical_storm_watch", "Tropical Storm Watch"),
96-
("storm_surge_warning", "Storm Surge Warning"),
97-
("storm_surge_watch", "Storm Surge Watch"),
98-
("red_flag_warning", "Red Flag Warning"),
99-
("fire_weather_watch", "Fire Weather Watch"),
100-
("fire_warning", "Generic fire warning"),
101-
("small_craft_advisory", "Small Craft Advisory"),
102-
("gale_warning", "Gale Warning"),
103-
("storm_warning", "Marine storm warning"),
104-
("hurricane_force_wind_warning", "Hurricane Force Wind Warning"),
105-
("special_marine_warning", "Special Marine Warning"),
106-
("dense_fog_advisory", "Dense Fog Advisory"),
107-
("fog_advisory", "Generic fog advisory"),
108-
("air_quality_alert", "Air Quality Alert"),
109-
("dust_storm_warning", "Dust Storm Warning"),
110-
("dust_advisory", "Dust Advisory"),
111-
("dust_warning", "Generic dust warning"),
112-
),
113-
),
43+
)
44+
45+
LEGACY_SOUND_EVENT_KEYS: frozenset[str] = frozenset(
46+
{
47+
"warning",
48+
"watch",
49+
"advisory",
50+
"statement",
51+
"tornado_warning",
52+
"tornado_watch",
53+
"thunderstorm_warning",
54+
"thunderstorm_watch",
55+
"flood_warning",
56+
"flood_watch",
57+
"flood_advisory",
58+
"flash_flood_warning",
59+
"flash_flood_watch",
60+
"coastal_flood_warning",
61+
"coastal_flood_watch",
62+
"coastal_flood_advisory",
63+
"river_flood_warning",
64+
"river_flood_watch",
65+
"excessive_heat_warning",
66+
"excessive_heat_watch",
67+
"heat_advisory",
68+
"winter_storm_warning",
69+
"winter_storm_watch",
70+
"winter_weather_advisory",
71+
"blizzard_warning",
72+
"ice_storm_warning",
73+
"ice_warning",
74+
"snow_warning",
75+
"snow_squall_warning",
76+
"freeze_warning",
77+
"freeze_watch",
78+
"frost_advisory",
79+
"extreme_cold_warning",
80+
"cold_weather_advisory",
81+
"high_wind_warning",
82+
"high_wind_watch",
83+
"wind_advisory",
84+
"wind_warning",
85+
"extreme_wind_warning",
86+
"hurricane_warning",
87+
"hurricane_watch",
88+
"tropical_storm_warning",
89+
"tropical_storm_watch",
90+
"storm_surge_warning",
91+
"storm_surge_watch",
92+
"red_flag_warning",
93+
"fire_weather_watch",
94+
"fire_warning",
95+
"small_craft_advisory",
96+
"gale_warning",
97+
"storm_warning",
98+
"hurricane_force_wind_warning",
99+
"special_marine_warning",
100+
"dense_fog_advisory",
101+
"fog_advisory",
102+
"air_quality_alert",
103+
"dust_storm_warning",
104+
"dust_advisory",
105+
"dust_warning",
106+
"tornado",
107+
"flood",
108+
"heat",
109+
"wind",
110+
"winter",
111+
"snow",
112+
"ice",
113+
"thunderstorm",
114+
"hurricane",
115+
"fire",
116+
"fog",
117+
"dust",
118+
"air_quality",
119+
}
114120
)
115121

116122
USER_MUTABLE_SOUND_EVENTS: tuple[tuple[str, str], ...] = tuple(
@@ -123,6 +129,8 @@
123129
event_key for event_key, _label in USER_MUTABLE_SOUND_EVENTS
124130
)
125131

132+
KNOWN_SOUND_EVENT_KEYS: frozenset[str] = USER_MUTABLE_SOUND_EVENT_KEYS | LEGACY_SOUND_EVENT_KEYS
133+
126134
FRIENDLY_SOUND_EVENT_CHOICES: tuple[tuple[str, str], ...] = tuple(
127135
(label, event_key) for event_key, label in USER_MUTABLE_SOUND_EVENTS
128136
)
@@ -147,7 +155,5 @@ def normalize_muted_sound_events(events: Collection[str] | None) -> list[str]:
147155
def normalize_known_muted_sound_events(events: Collection[str] | None) -> list[str]:
148156
"""Normalize muted events and drop unknown keys from the shared catalog."""
149157
return [
150-
event
151-
for event in normalize_muted_sound_events(events)
152-
if event in USER_MUTABLE_SOUND_EVENT_KEYS
158+
event for event in normalize_muted_sound_events(events) if event in KNOWN_SOUND_EVENT_KEYS
153159
]

0 commit comments

Comments
 (0)