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
32 changes: 24 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,30 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
# Use prebuilt wx wheels only — never build from source.
# extras.wxpython.org provides Linux wheels for supported Python versions.
pip install \
-f https://extras.wxpython.org/wxPython4/extras/linux/gtk3/ubuntu-24.04 \
--only-binary wxPython \
wxPython
pip install -r requirements-dev.txt
pip install -e .
# Unit tests use in-repo stubs for wx and sound_lib when
# those GUI/audio runtime packages are unavailable.
# Keep CI validation independent from extras.wxpython.org wheel availability;
# packaging workflows still install real wxPython for build artifacts.
python - <<'PY'
from pathlib import Path

runtime_stubs = ("wxpython", "sound_lib @")
filters = {
"requirements.txt": Path("/tmp/requirements-ci.txt"),
"requirements-dev.txt": Path("/tmp/requirements-dev-ci.txt"),
}
for source, target in filters.items():
kept = []
for line in Path(source).read_text(encoding="utf-8").splitlines():
stripped = line.strip().lower()
if stripped == "-r requirements.txt" or stripped.startswith(runtime_stubs):
continue
kept.append(line)
target.write_text("\n".join(kept) + "\n", encoding="utf-8")
PY
pip install -r /tmp/requirements-ci.txt
pip install -r /tmp/requirements-dev-ci.txt
pip install --no-deps -e .

- name: Check CHANGELOG entry
if: matrix.primary
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
## [Unreleased]

### Added
- Settings > Audio can now enable specific-alert sounds per sound pack, so packs with sounds like `tornado_watch` and `tornado_warning` keep working while severity-only packs stay simple.
- First-run setup can now import existing settings and encrypted API keys from the wizard, or exit with Escape at any wizard step when you want to configure everything yourself.
- You can now search for a US street address when adding or editing a location, so AccessiWeather can save coordinates for that specific address instead of the nearest city or ZIP result.
- Saved locations now stay sorted alphabetically in the location list (#667).
Expand Down
15 changes: 14 additions & 1 deletion docs/SOUND_PACK_SYSTEM.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Each sound pack is a directory containing:
"author": "Author Name",
"description": "Pack description",
"version": "1.0.0",
"specific_alert_sounds": false,
"sounds": {
"alert": "alert_sound.wav",
"notify": "notification_sound.wav",
Expand Down Expand Up @@ -101,7 +102,19 @@ AccessiWeather maps alert notifications by severity first, then falls back to

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.
but new packs should use the compact severity keys above by default.

If a pack already contains old specific alert keys, AccessiWeather
automatically tries those keys for that pack before the severity keys. Pack
authors can also set `"specific_alert_sounds": true` in `pack.json` to opt in
explicitly.

Users who want different sounds for specific alerts in a severity-only pack can
turn on **Use specific alert sounds for this sound pack** in Settings > Audio.
That checkbox applies only to the selected pack. For example, a Tornado Warning
can use `tornado_warning`, while a Tornado Watch can use `tornado_watch`; if
those sounds are missing, playback still falls back to severity, then `alert`,
then `notify`.

## Built-in Sound Packs

Expand Down
1 change: 0 additions & 1 deletion installer/accessiweather.spec
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,6 @@ hiddenimports = [
"wx.lib.agw.aui",
"wx.lib.mixins",
"wx.lib.mixins.inspection",
"gui_builder",
"httpx",
"httpx._transports",
"httpx._transports.default",
Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ license = {text = "MIT"}
requires-python = ">=3.11"
dependencies = [
"wxPython>=4.2.5",
"gui_builder @ git+https://github.com/accessibleapps/gui_builder.git",
"sound_lib @ git+https://github.com/samtupy/sound_lib_macos_fixes.git",
"platform-utils>=1.6.0", # sound_lib dep; >=1.6 needed for Python 3.12+ (no imp module)
"desktop-notifier",
Expand Down
1 change: 0 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# wxPython UI framework
wxPython>=4.2.0
gui_builder @ git+https://github.com/accessibleapps/gui_builder.git

# Audio
sound_lib @ git+https://github.com/accessibleapps/sound_lib.git
Expand Down
20 changes: 19 additions & 1 deletion src/accessiweather/alert_notification_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,21 @@ def _trigger_immediate_alert_popup_if_enabled(
except Exception as exc:
logger.error("Failed to trigger immediate alert popup: %s", exc)

def _should_use_specific_alert_sounds(self) -> bool:
"""Return whether current sound-pack settings should try specific alert keys."""
pack = getattr(self.settings, "sound_pack", "default")
specific_packs = set(getattr(self.settings, "specific_alert_sound_packs", []) or [])
if pack in specific_packs:
return True

try:
from .notifications.sound_player import sound_pack_uses_specific_alert_sounds

return sound_pack_uses_specific_alert_sounds(pack)
except Exception as e:
logger.debug(f"[notify] Could not inspect sound pack for alert sound mode: {e}")
return False

async def _send_alert_notification(
self, alert: WeatherAlert, reason: str, play_sound: bool = True
) -> bool:
Expand Down Expand Up @@ -198,7 +213,10 @@ async def _send_alert_notification(
try:
from .notifications.alert_sound_mapper import get_candidate_sound_events

sound_candidates = get_candidate_sound_events(alert)
sound_candidates = get_candidate_sound_events(
alert,
include_specific_events=self._should_use_specific_alert_sounds(),
)
logger.debug(f"[notify] Sound candidates for alert: {sound_candidates}")
except Exception as e:
logger.debug(f"[notify] Sound mapper unavailable: {e}")
Expand Down
1 change: 1 addition & 0 deletions src/accessiweather/models/config_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"sound_enabled",
"sound_pack",
"muted_sound_events",
"specific_alert_sound_packs",
# Event notifications
"notify_discussion_update",
"notify_hwo_update",
Expand Down
10 changes: 10 additions & 0 deletions src/accessiweather/models/config_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def to_dict(self) -> dict:
"sound_enabled": settings.sound_enabled,
"sound_pack": settings.sound_pack,
"muted_sound_events": settings.muted_sound_events,
"specific_alert_sound_packs": settings.specific_alert_sound_packs,
"notify_discussion_update": settings.notify_discussion_update,
"notify_hwo_update": settings.notify_hwo_update,
"notify_sps_issued": settings.notify_sps_issued,
Expand Down Expand Up @@ -110,6 +111,13 @@ def to_dict(self) -> dict:
def from_dict(cls, data: dict) -> AppSettings:
"""Create from dictionary."""
settings_cls = cast("type[AppSettings]", cls)
specific_alert_sound_packs = data.get("specific_alert_sound_packs")
if specific_alert_sound_packs is None:
specific_alert_sound_packs = []
if settings_cls._as_bool(data.get("specific_alert_sounds_enabled"), False):
sound_pack = str(data.get("sound_pack", "default")).strip() or "default"
specific_alert_sound_packs = [sound_pack]

settings = settings_cls(
temperature_unit=data.get("temperature_unit", "both"),
update_interval_minutes=data.get("update_interval_minutes", 10),
Expand All @@ -125,6 +133,7 @@ def from_dict(cls, data: dict) -> AppSettings:
sound_enabled=settings_cls._as_bool(data.get("sound_enabled"), True),
sound_pack=data.get("sound_pack", "default"),
muted_sound_events=data.get("muted_sound_events", list(DEFAULT_MUTED_SOUND_EVENTS)),
specific_alert_sound_packs=specific_alert_sound_packs,
notify_discussion_update=settings_cls._as_bool(
data.get("notify_discussion_update"), True
),
Expand Down Expand Up @@ -261,6 +270,7 @@ def from_dict(cls, data: dict) -> AppSettings:
settings.validate_on_access("auto_sources_us")
settings.validate_on_access("auto_sources_international")
settings.validate_on_access("parallel_fetch_timeout")
settings.validate_on_access("specific_alert_sound_packs")
if settings.data_source not in {"auto", "nws", "openmeteo", "pirateweather"}:
settings.data_source = "auto"
return settings
Expand Down
1 change: 1 addition & 0 deletions src/accessiweather/models/config_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class AppSettings(AppSettingsValidationMixin, AppSettingsSerializationMixin):
sound_enabled: bool = True
sound_pack: str = "default"
muted_sound_events: list[str] = field(default_factory=lambda: list(DEFAULT_MUTED_SOUND_EVENTS))
specific_alert_sound_packs: list[str] = field(default_factory=list)
# Event-based notifications
notify_discussion_update: bool = True
notify_hwo_update: bool = True
Expand Down
13 changes: 13 additions & 0 deletions src/accessiweather/models/config_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,19 @@ def validate_on_access(self, setting_name: str) -> bool:
normalized = normalize_known_muted_sound_events(value)
setattr(settings, setting_name, normalized)

elif setting_name == "specific_alert_sound_packs":
if not isinstance(value, list):
setattr(settings, setting_name, [])
else:
normalized = []
seen = set()
for item in value:
pack = str(item).strip()
if pack and pack not in seen:
seen.add(pack)
normalized.append(pack)
setattr(settings, setting_name, normalized)

elif setting_name == "taskbar_icon_text_format":
# Ensure format string is valid
if not isinstance(value, str) or not value.strip():
Expand Down
119 changes: 109 additions & 10 deletions src/accessiweather/notifications/alert_sound_mapper.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
"""
Alert-to-sound mapping utilities.

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.
This module maps weather alerts to sound event keys. The default path is
severity-first so providers do not need exact event text to agree before a
useful sound can play. Users can opt into specific alert sounds, which tries
normalized alert-event keys before the severity fallback.
"""

from __future__ import annotations

import re

from ..models import WeatherAlert

KNOWN_ALERT_TYPE_KEYS = [
"warning",
"watch",
"advisory",
"statement",
]

KNOWN_SEVERITY_KEYS = [
"extreme",
"severe",
Expand All @@ -21,6 +31,23 @@
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:
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 @@ -37,27 +64,99 @@ def _normalize_severity(sev: str | None) -> str | None:
return alias if alias in KNOWN_SEVERITY_KEYS else None


def get_candidate_sound_events(alert: WeatherAlert) -> list[str]:
HAZARD_KEYWORDS = {
"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
s = text.strip().lower()
s = re.sub(r"[^a-z0-9]+", "_", s)
s = re.sub(r"_+", "_", s)
s = s.strip("_")
return s or None


def _add_unique(candidates: list[str], key: str | None) -> None:
if key and key not in candidates:
candidates.append(key)


def _add_specific_candidates(alert: WeatherAlert, candidates: list[str], sev: str | None) -> None:
normalized_event = _normalize_event_to_key(getattr(alert, "event", None))
_add_unique(candidates, normalized_event)

atype = _extract_alert_type(alert)
hazard = _extract_hazard(alert)

if hazard and atype:
_add_unique(candidates, f"{hazard}_{atype}")
if hazard and sev:
_add_unique(candidates, f"{hazard}_{sev}")
_add_unique(candidates, hazard)
_add_unique(candidates, atype)


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

Order of preference:
- Optional specific alert keys when include_specific_events is true
- Severity level (extreme/severe/moderate/minor), including provider aliases
- Generic fallbacks (alert, notify)
"""
candidates: list[str] = []

sev = _normalize_severity(getattr(alert, "severity", None))
if sev and sev not in candidates:
candidates.append(sev)
if include_specific_events:
_add_specific_candidates(alert, candidates, sev)

_add_unique(candidates, sev)

for fb in GENERIC_FALLBACKS:
if fb not in candidates:
candidates.append(fb)
_add_unique(candidates, fb)

return candidates


def choose_sound_event(alert: WeatherAlert) -> str:
def choose_sound_event(alert: WeatherAlert, *, include_specific_events: bool = False) -> str:
"""Return the preferred sound event key for this alert (first candidate)."""
return get_candidate_sound_events(alert)[0]
return get_candidate_sound_events(alert, include_specific_events=include_specific_events)[0]
Loading