Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ All notable changes to this project will be documented in this file.

### Changed
- Linux nightly and release downloads now ship as `.tar.gz` tarballs instead of ZIP files.
- 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.

### Fixed
- Saving Settings no longer waits on the Windows startup shortcut check unless you actually change the launch-at-startup checkbox.
Expand Down
36 changes: 17 additions & 19 deletions docs/SOUND_PACK_SYSTEM.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,28 +82,26 @@ Both formats can be mixed - inline volume takes precedence if specified.
- `notify` - General notifications
- `error` - Error conditions and failures
- `success` - Successful operations
- `data_updated` - Weather refresh completed
- `fetch_error` - Weather refresh failed
- `discussion_update` - Forecast discussion updated
- `severe_risk` - Severe weather risk changed
- `startup` - Application startup sound
- `exit` - Application exit sound

### Weather-Specific Sound Events

AccessiWeather supports specific sound mappings for different types of weather alerts:

- `tornado_warning` - Tornado warnings (highest priority)
- `thunderstorm_warning` - Severe thunderstorm warnings
- `flood_warning` - Flood warnings
- `heat_advisory` - Heat advisories and excessive heat warnings
- `winter_storm_warning` - Winter storm warnings
- `hurricane_warning` - Hurricane warnings
- `wind_warning` - High wind warnings
- `fire_warning` - Fire weather warnings
- `air_quality_alert` - Air quality alerts
- `fog_advisory` - Dense fog advisories
- `ice_warning` - Ice storm warnings
- `snow_warning` - Heavy snow warnings
- `dust_warning` - Dust storm warnings
- `warning` - Generic severe weather warnings
- `watch` - Generic weather watches
### Alert Severity Sound Events

AccessiWeather maps alert notifications by severity first, then falls back to
`alert` and `notify` when a pack does not provide the severity key.

- `extreme` - Extreme severity alerts
- `severe` - Severe severity alerts
- `moderate` - Moderate severity alerts
- `minor` - Minor severity alerts

Older sound packs can keep specific keys such as `tornado_warning`, `warning`,
or `watch`; AccessiWeather still tolerates those mappings for compatibility,
but new packs should use the compact severity keys above.

## Built-in Sound Packs

Expand Down
122 changes: 4 additions & 118 deletions src/accessiweather/notifications/alert_sound_mapper.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,15 @@
"""
Alert-to-sound mapping utilities.

This module provides a small, dependency-free mapper that determines which
sound "event" key should be used for a given WeatherAlert. It produces an
ordered list of candidate event keys, allowing the sound system to try the
first available one in the currently selected pack, then gracefully fall back
by severity and finally to generic defaults.
This module maps weather alerts to a compact set of sound event keys. Alert
sounds are intentionally severity-first so providers do not need exact event
text to agree before a useful sound can play.
"""

from __future__ import annotations

import re

from ..models import WeatherAlert

# Normalized keys we may support in sound packs.
# Packs remain simple JSON dictionaries of event->filename.
# We don't require all keys; missing keys are skipped with fallback.
KNOWN_ALERT_TYPE_KEYS = [
"warning",
"watch",
"advisory",
"statement",
]

KNOWN_SEVERITY_KEYS = [
"extreme",
"severe",
Expand All @@ -35,24 +21,6 @@
GENERIC_FALLBACKS = ["alert", "notify"]


def _contains_token(text: str | None, token: str) -> bool:
if not text:
return False
return re.search(rf"\b{re.escape(token)}\b", text, flags=re.IGNORECASE) is not None


def _extract_alert_type(alert: WeatherAlert) -> str | None:
# Look at event and headline/title for NWS-style type words
for key in KNOWN_ALERT_TYPE_KEYS:
if (
_contains_token(alert.event, key)
or _contains_token(alert.headline, key)
or _contains_token(alert.title, key)
):
return key
return None


def _normalize_severity(sev: str | None) -> str | None:
if not sev:
return None
Expand All @@ -69,99 +37,17 @@ def _normalize_severity(sev: str | None) -> str | None:
return alias if alias in KNOWN_SEVERITY_KEYS else None


HAZARD_KEYWORDS = {
# core
"flood": ["flood"],
"tornado": ["tornado"],
"heat": ["heat", "excessive heat"],
"wind": ["wind", "high wind"],
"winter": ["winter", "winter storm"],
"snow": ["snow", "heavy snow"],
"ice": ["ice", "freezing rain", "freezing drizzle"],
"thunderstorm": ["thunderstorm", "severe thunderstorm"],
"hurricane": ["hurricane"],
"fire": ["fire", "red flag"],
"fog": ["fog", "dense fog"],
"dust": ["dust", "blowing dust"],
"air_quality": ["air quality", "smoke"],
}


def _extract_hazard(alert: WeatherAlert) -> str | None:
text = " ".join(
[
getattr(alert, "event", "") or "",
getattr(alert, "headline", "") or "",
getattr(alert, "title", "") or "",
getattr(alert, "description", "") or "",
]
).lower()
for hazard_key, phrases in HAZARD_KEYWORDS.items():
for phrase in phrases:
if phrase in text:
return hazard_key
return None


def _normalize_event_to_key(text: str | None) -> str | None:
"""
Normalize an alert event/title/headline to a pack key.

Example: "Excessive Heat Watch" -> "excessive_heat_watch"
"""
if not text:
return None
import re as _re

s = text.strip().lower()
# Replace any non-alphanumeric with underscores
s = _re.sub(r"[^a-z0-9]+", "_", s)
# Collapse multiple underscores
s = _re.sub(r"_+", "_", s)
# Trim leading/trailing underscores
s = s.strip("_")
return s or None


def get_candidate_sound_events(alert: WeatherAlert) -> list[str]:
"""
Return an ordered list of candidate sound event keys for an alert.

Order of preference:
- Exact normalized event key from alert.event (e.g., excessive_heat_watch)
- Hazard + Type (e.g., flood_warning) if both can be detected
- Hazard + Severity (e.g., heat_extreme) if detected
- Hazard only (e.g., flood, heat)
- Specific alert type (warning/watch/advisory/statement)
- Severity level (extreme/severe/moderate/minor)
- Severity level (extreme/severe/moderate/minor), including provider aliases
- Generic fallbacks (alert, notify)
"""
candidates: list[str] = []

# Exact normalized event key first
normalized_event = _normalize_event_to_key(getattr(alert, "event", None))
if normalized_event:
candidates.append(normalized_event)

atype = _extract_alert_type(alert)
sev = _normalize_severity(getattr(alert, "severity", None))
hazard = _extract_hazard(alert)

# Hazard combinations next
if hazard and atype:
key = f"{hazard}_{atype}"
if key not in candidates:
candidates.append(key)
if hazard and sev:
key = f"{hazard}_{sev}"
if key not in candidates:
candidates.append(key)
if hazard and hazard not in candidates:
candidates.append(hazard)

# Then type and severity
if atype and atype not in candidates:
candidates.append(atype)
if sev and sev not in candidates:
candidates.append(sev)

Expand Down
158 changes: 82 additions & 76 deletions src/accessiweather/sound_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,86 +31,92 @@
),
),
(
"Alert severity fallbacks",
"Fallbacks by alert severity.",
"Alert severities",
"Weather alert sounds by severity.",
(
("extreme", "Extreme severity"),
("severe", "Severe severity"),
("moderate", "Moderate severity"),
("minor", "Minor severity"),
),
),
(
"Alert type fallbacks",
"Fallbacks by alert type.",
(
("warning", "Generic warning"),
("watch", "Generic watch"),
("advisory", "Generic advisory"),
("statement", "Generic statement"),
),
),
(
"Alert events",
"Specific alert sounds.",
(
("tornado_warning", "Tornado Warning"),
("tornado_watch", "Tornado Watch"),
("thunderstorm_warning", "Severe Thunderstorm Warning"),
("thunderstorm_watch", "Severe Thunderstorm Watch"),
("flood_warning", "Flood Warning"),
("flood_watch", "Flood Watch"),
("flood_advisory", "Flood Advisory"),
("flash_flood_warning", "Flash Flood Warning"),
("flash_flood_watch", "Flash Flood Watch"),
("coastal_flood_warning", "Coastal Flood Warning"),
("coastal_flood_watch", "Coastal Flood Watch"),
("coastal_flood_advisory", "Coastal Flood Advisory"),
("river_flood_warning", "River Flood Warning"),
("river_flood_watch", "River Flood Watch"),
("excessive_heat_warning", "Excessive Heat Warning"),
("excessive_heat_watch", "Excessive Heat Watch"),
("heat_advisory", "Heat Advisory"),
("winter_storm_warning", "Winter Storm Warning"),
("winter_storm_watch", "Winter Storm Watch"),
("winter_weather_advisory", "Winter Weather Advisory"),
("blizzard_warning", "Blizzard Warning"),
("ice_storm_warning", "Ice Storm Warning"),
("ice_warning", "Generic ice warning"),
("snow_warning", "Generic snow warning"),
("snow_squall_warning", "Snow Squall Warning"),
("freeze_warning", "Freeze Warning"),
("freeze_watch", "Freeze Watch"),
("frost_advisory", "Frost Advisory"),
("extreme_cold_warning", "Extreme Cold Warning"),
("cold_weather_advisory", "Cold Weather Advisory"),
("high_wind_warning", "High Wind Warning"),
("high_wind_watch", "High Wind Watch"),
("wind_advisory", "Wind Advisory"),
("wind_warning", "Generic wind warning"),
("extreme_wind_warning", "Extreme Wind Warning"),
("hurricane_warning", "Hurricane Warning"),
("hurricane_watch", "Hurricane Watch"),
("tropical_storm_warning", "Tropical Storm Warning"),
("tropical_storm_watch", "Tropical Storm Watch"),
("storm_surge_warning", "Storm Surge Warning"),
("storm_surge_watch", "Storm Surge Watch"),
("red_flag_warning", "Red Flag Warning"),
("fire_weather_watch", "Fire Weather Watch"),
("fire_warning", "Generic fire warning"),
("small_craft_advisory", "Small Craft Advisory"),
("gale_warning", "Gale Warning"),
("storm_warning", "Marine storm warning"),
("hurricane_force_wind_warning", "Hurricane Force Wind Warning"),
("special_marine_warning", "Special Marine Warning"),
("dense_fog_advisory", "Dense Fog Advisory"),
("fog_advisory", "Generic fog advisory"),
("air_quality_alert", "Air Quality Alert"),
("dust_storm_warning", "Dust Storm Warning"),
("dust_advisory", "Dust Advisory"),
("dust_warning", "Generic dust warning"),
),
),
)

LEGACY_SOUND_EVENT_KEYS: frozenset[str] = frozenset(
{
"warning",
"watch",
"advisory",
"statement",
"tornado_warning",
"tornado_watch",
"thunderstorm_warning",
"thunderstorm_watch",
"flood_warning",
"flood_watch",
"flood_advisory",
"flash_flood_warning",
"flash_flood_watch",
"coastal_flood_warning",
"coastal_flood_watch",
"coastal_flood_advisory",
"river_flood_warning",
"river_flood_watch",
"excessive_heat_warning",
"excessive_heat_watch",
"heat_advisory",
"winter_storm_warning",
"winter_storm_watch",
"winter_weather_advisory",
"blizzard_warning",
"ice_storm_warning",
"ice_warning",
"snow_warning",
"snow_squall_warning",
"freeze_warning",
"freeze_watch",
"frost_advisory",
"extreme_cold_warning",
"cold_weather_advisory",
"high_wind_warning",
"high_wind_watch",
"wind_advisory",
"wind_warning",
"extreme_wind_warning",
"hurricane_warning",
"hurricane_watch",
"tropical_storm_warning",
"tropical_storm_watch",
"storm_surge_warning",
"storm_surge_watch",
"red_flag_warning",
"fire_weather_watch",
"fire_warning",
"small_craft_advisory",
"gale_warning",
"storm_warning",
"hurricane_force_wind_warning",
"special_marine_warning",
"dense_fog_advisory",
"fog_advisory",
"air_quality_alert",
"dust_storm_warning",
"dust_advisory",
"dust_warning",
"tornado",
"flood",
"heat",
"wind",
"winter",
"snow",
"ice",
"thunderstorm",
"hurricane",
"fire",
"fog",
"dust",
"air_quality",
}
)

USER_MUTABLE_SOUND_EVENTS: tuple[tuple[str, str], ...] = tuple(
Expand All @@ -123,6 +129,8 @@
event_key for event_key, _label in USER_MUTABLE_SOUND_EVENTS
)

KNOWN_SOUND_EVENT_KEYS: frozenset[str] = USER_MUTABLE_SOUND_EVENT_KEYS | LEGACY_SOUND_EVENT_KEYS

FRIENDLY_SOUND_EVENT_CHOICES: tuple[tuple[str, str], ...] = tuple(
(label, event_key) for event_key, label in USER_MUTABLE_SOUND_EVENTS
)
Expand All @@ -147,7 +155,5 @@ def normalize_muted_sound_events(events: Collection[str] | None) -> list[str]:
def normalize_known_muted_sound_events(events: Collection[str] | None) -> list[str]:
"""Normalize muted events and drop unknown keys from the shared catalog."""
return [
event
for event in normalize_muted_sound_events(events)
if event in USER_MUTABLE_SOUND_EVENT_KEYS
event for event in normalize_muted_sound_events(events) if event in KNOWN_SOUND_EVENT_KEYS
]
Loading