Add French language support

main
swanie98635 2 months ago
parent 8d6ab03a45
commit 83699eb073

@ -42,7 +42,33 @@ class AudioHandler:
if os.path.exists(raw_path):
os.remove(raw_path)
cmd = self.tts_template.format(file=raw_path, text=segment)
# Prepare language flag (simplistic handling for pico2wave)
lang = self.config.get('station', {}).get('announce_language', 'en')
lang_arg = ""
# Only inject if using standard pico2wave command and specifically French
if 'pico2wave' in self.tts_template and lang == 'fr':
lang_arg = "-l fr-FR"
elif 'pico2wave' in self.tts_template and lang == 'en':
lang_arg = "-l en-US" # explicit default
# Inject into template if it supports it, or just append to command construction
# But self.tts_template is a string like 'pico2wave -w {file} "{text}"'
# We need to hack it in or assume user configured it.
# BETTER APPROACH:
# If tts_command is default, we can modify it.
# If user provided custom command, we assume they handle it or we don't touch it.
final_cmd = self.tts_template.format(file=raw_path, text=segment)
# Injection hack for pico2wave if using default-ish config
if lang == 'fr' and 'pico2wave' in final_cmd and '-l ' not in final_cmd:
final_cmd = final_cmd.replace('pico2wave', 'pico2wave -l fr-FR')
elif lang == 'en' and 'pico2wave' in final_cmd and '-l ' not in final_cmd:
# Optional: explicit English
final_cmd = final_cmd.replace('pico2wave', 'pico2wave -l en-US')
cmd = final_cmd
self.logger.info(f"Generating segment {i}: {cmd}")
subprocess.run(cmd, shell=True, check=True)

@ -5,15 +5,104 @@ import re
from .models import LocationInfo, CurrentConditions, WeatherForecast, WeatherAlert
class Narrator:
STRINGS = {
'en': {
'current_conditions_intro': "Current conditions for {city}, {region}.",
'temperature': "The temperature is {val} {unit}.",
'conditions': "Conditions are {desc}{wind}.",
'wind_with_dir': ", with winds from the {dir} at {val} {unit}",
'wind_no_dir': ", with winds at {val} {unit}",
'forecast_intro': "Here is the forecast.",
'forecast_item': "{period}: {cond}.{temp}",
'no_alerts': "There are no active weather alerts.",
'active_alerts_count': "There are {count} active weather alerts. ",
'alert_issued': "A {title} was issued at {time}. ",
'alert_effect': "A {title} is in effect. ",
'alert_effective_from': "It is effective from {time}. ",
'alert_expires': "It expires at {time}. ",
'clear': "{callsign} Clear.",
'time_msg': "The time is {time} {tz}. Time provided by the {agency}.",
'automated_report': "This is the automated weather report for {city}, {region}. Break. [PAUSE]",
'please_advise': "Please be advised: {alerts} Break. [PAUSE]",
'system_status': "System status: Monitoring for {sev} level events and higher. Tone alerts active for {tone_sev} events and higher. Monitoring interval is {interval} minutes. Break. [PAUSE]",
'code_source': "A S L 3 underscore w x underscore announce is available on github and developed by N 7 X O B as non-commercial code provided for the benefit of the amateur radio community.",
'data_source_us': "Data provided by the National Weather Service and the National Institute of Standards and Technology.",
'data_source_ca': "Data provided by Environment Canada, National Research Council Canada, and the National Alert Aggregation and Dissemination System.",
'intro_cq': "CQ CQ CQ. This is {callsign} with the updated weather report.",
'intro_good_day': "Good day.",
'concludes': "This concludes the weather report for {city}. {callsign} Clear.",
'degrees': "degrees",
'degrees_celsius': "degrees celsius",
'mph': "miles per hour",
'kph': "kilometers per hour",
'high': " High",
'low': " Low",
# Test & Monitor Strings - (Simplified for brevity, can add more)
'test_preamble': "{callsign} Testing. The following is an alerting test of an automated alerting notification and message. Do not take any action as a result of the following message. Repeating - take no action - this is only a test. The test tones will follow in 10 seconds.",
'test_message': "This is a test message. This is a sample emergency test message. This is only a test. No action is required.",
'test_postamble': "{callsign} reminds you this was a test. Do not take action. Repeat - take no action. If this was an actual emergency, the preceding tone would be followed by specific information from official authorities on an imminent emergency that requires your immediate attention and possibly action to prevent loss of life, injury or property damage. This test is concluded.",
'monitor_online': "{callsign} Automatic Monitoring Online. Data provided by {source}. {nodes}Current location {city}. {time_msg} Active Alert Check Interval {interval} minutes. Dynamic Alert Check Interval {active} minutes.",
'interval_change_active': "{callsign} Notification. The monitoring interval is being changed to {interval} minutes due to active alerts in the area.",
'interval_change_normal': "{callsign} Notification. Active alerts have expired. The monitoring interval is being changed back to {interval} minutes."
},
'fr': {
'current_conditions_intro': "Conditions actuelles pour {city}, {region}.",
'temperature': "La température est de {val} {unit}.",
'conditions': "Les conditions sont {desc}{wind}.",
'wind_with_dir': ", avec des vents du {dir} à {val} {unit}",
'wind_no_dir': ", avec des vents à {val} {unit}",
'forecast_intro': "Voici les prévisions.",
'forecast_item': "{period}: {cond}.{temp}",
'no_alerts': "Il n'y a aucune alerte météo active.",
'active_alerts_count': "Il y a {count} alertes météo actives. ",
'alert_issued': "Une {title} a été émise à {time}. ",
'alert_effect': "Une {title} est en vigueur. ",
'alert_effective_from': "Elle est en vigueur à partir de {time}. ",
'alert_expires': "Elle expire à {time}. ",
'clear': "{callsign} Terminé.",
'time_msg': "Il est {time} {tz}. Heure fournie par {agency}.",
'automated_report': "Ceci est le rapport météo automatisé pour {city}, {region}. Pause. [PAUSE]",
'please_advise': "Veuillez noter : {alerts} Pause. [PAUSE]",
'system_status': "État du système : Surveillance des événements de niveau {sev} et supérieurs. Alertes sonores actives pour les événements {tone_sev} et supérieurs. L'intervalle de surveillance est de {interval} minutes. Pause. [PAUSE]",
'code_source': "A S L 3 underscore w x underscore announce est disponible sur github et développé par N 7 X O B comme code non commercial fourni au bénéfice de la communauté radioamateur.",
'data_source_us': "Données fournies par le National Weather Service et le National Institute of Standards and Technology.",
'data_source_ca': "Données fournies par Environnement Canada, le Conseil national de recherches du Canada et le Système national d'agrégation et de diffusion des alertes.",
'intro_cq': "CQ CQ CQ. Ici {callsign} avec le rapport météo mis à jour.",
'intro_good_day': "Bonjour.",
'concludes': "Ceci conclut le rapport météo pour {city}. {callsign} Terminé.",
'degrees': "degrés",
'degrees_celsius': "degrés celsius",
'mph': "milles par heure",
'kph': "kilomètres par heure",
'high': " Maximum",
'low': " Minimum",
# Test & Monitor Strings (French translations approx)
'test_preamble': "{callsign} Essai. Ceci est un test d'alerte d'un système de notification et de message automatisé. Ne prenez aucune mesure suite au message suivant. Je répète - ne prenez aucune mesure - ceci n'est qu'un test. Les tonalités d'essai suivront dans 10 secondes.",
'test_message': "Ceci est un message de test. Ceci est un exemple de message d'urgence. Ceci n'est qu'un test. Aucune action n'est requise.",
'test_postamble': "{callsign} vous rappelle que ceci était un test. Ne prenez aucune mesure. Je répète - ne prenez aucune mesure. S'il s'agissait d'une urgence réelle, la tonalité précédente aurait été suivie d'informations spécifiques des autorités officielles sur une urgence imminente nécessitant votre attention immédiate et éventuellement une action pour prévenir la perte de vie, des blessures ou des dommages matériels. Ce test est terminé.",
'monitor_online': "{callsign} Surveillance automatique en ligne. Données fournies par {source}. {nodes}Emplacement actuel {city}. {time_msg} Intervalle de vérification des alertes actives {interval} minutes. Intervalle de vérification dynamique {active} minutes.",
'interval_change_active': "{callsign} Notification. L'intervalle de surveillance est modifié à {interval} minutes en raison d'alertes actives dans la région.",
'interval_change_normal': "{callsign} Notification. Les alertes actives ont expiré. L'intervalle de surveillance revient à {interval} minutes."
}
}
def __init__(self, config: dict = None):
self.config = config or {}
# Determine language (default en)
self.lang = self.config.get('station', {}).get('announce_language', 'en')
if self.lang not in self.STRINGS:
self.lang = 'en'
def _get_str(self, key: str, **kwargs) -> str:
tmpl = self.STRINGS.get(self.lang, {}).get(key, self.STRINGS['en'].get(key, ""))
return tmpl.format(**kwargs)
def announce_conditions(self, loc: LocationInfo, current: CurrentConditions) -> str:
is_us = (loc.country_code or "").upper() == 'US'
# Temp Logic
temp_val = int(current.temperature)
temp_unit = "degrees celsius"
temp_unit = "degrees_celsius"
if is_us:
temp_val = int((current.temperature * 9/5) + 32)
temp_unit = "degrees" # US usually assumes F
@ -30,26 +119,29 @@ class Narrator:
"S": "South", "SSW": "South Southwest", "SW": "Southwest", "WSW": "West Southwest",
"W": "West", "WNW": "West Northwest", "NW": "Northwest", "NNW": "North Northwest"
}
# French Directions (TODO: Complete list or keep EN for now if simple)
# For now, simplistic approach: if lang is FR, we might need a separate map or just rely on EN abbreviations being understood or adding translation map.
# Let's add basic map for FR if needed, or just let it pass for now.
if direction and direction in dirs:
direction = dirs[direction]
# Wind Speed Logic
speed_val = int(current.wind_speed)
speed_unit = "kilometers per hour"
speed_unit = self._get_str('kph')
if is_us:
speed_val = int(current.wind_speed * 0.621371)
speed_unit = "miles per hour"
speed_unit = self._get_str('mph')
if direction:
wind = f", with winds from the {direction} at {speed_val} {speed_unit}"
wind = self._get_str('wind_with_dir', dir=direction, val=speed_val, unit=speed_unit)
else:
wind = f", with winds at {speed_val} {speed_unit}"
wind = self._get_str('wind_no_dir', val=speed_val, unit=speed_unit)
return (
f"Current conditions for {loc.city}, {loc.region}. "
f"The temperature is {temp_val} {temp_unit}. "
f"Conditions are {current.description}{wind}."
self._get_str('current_conditions_intro', city=loc.city, region=loc.region) + " " +
self._get_str('temperature', val=temp_val, unit=self._get_str(temp_unit)) + " " + # temp_unit is key 'degrees' or 'degrees_celsius' now
self._get_str('conditions', desc=current.description, wind=wind)
)
def announce_forecast(self, forecasts: List[WeatherForecast], loc: LocationInfo, verbose: bool = True) -> str:
@ -58,7 +150,7 @@ class Narrator:
is_us = (loc.country_code or "").upper() == 'US'
text = "Here is the forecast. "
text = self._get_str('forecast_intro') + " "
limit = 4 if not verbose else len(forecasts)
@ -67,24 +159,24 @@ class Narrator:
if f.high_temp is not None:
val = f.high_temp
if is_us: val = (val * 9/5) + 32
temp = f" High {int(val)}."
temp = self._get_str('high') + f" {int(val)}."
elif f.low_temp is not None:
val = f.low_temp
if is_us: val = (val * 9/5) + 32
temp = f" Low {int(val)}."
temp = self._get_str('low') + f" {int(val)}."
# Always prefer short_summary if available for the new style
condition = f.short_summary if f.short_summary else f.summary
text += f"{f.period_name}: {condition}.{temp} Break. [PAUSE] "
text += self._get_str('forecast_item', period=f.period_name, cond=condition, temp=temp) + " Break. [PAUSE] "
return text
def announce_alerts(self, alerts: List[WeatherAlert], loc: LocationInfo = None) -> str:
if not alerts:
return "There are no active weather alerts."
return self._get_str('no_alerts')
text = f"There are {len(alerts)} active weather alerts. "
text = self._get_str('active_alerts_count', count=len(alerts))
for a in alerts:
# Format times
fmt = "%I %M %p"
@ -94,29 +186,26 @@ class Narrator:
exp_str = a.expires.strftime(fmt)
# Construct message
item_text = f"A {a.title} "
if issued_str:
item_text += f"was issued at {issued_str}. "
text += self._get_str('alert_issued', title=a.title, time=issued_str)
else:
item_text += "is in effect. "
text += self._get_str('alert_effect', title=a.title)
# If effective is different from issued, mention it
if a.effective and a.issued and abs((a.effective - a.issued).total_seconds()) > 600: # >10 mins diff
item_text += f"It is effective from {eff_str}. "
text += self._get_str('alert_effective_from', time=eff_str)
# Expires
item_text += f"It expires at {exp_str}. "
text += item_text
text += self._get_str('alert_expires', time=exp_str)
# Add sign-off to alerts
if loc:
full_callsign = self.get_full_callsign(loc)
text += f" {full_callsign} Clear."
text += " " + self._get_str('clear', callsign=full_callsign)
else:
# Fallback if no loc passed (though we should always pass it)
callsign = self.config.get('station', {}).get('callsign')
if callsign: text += f" {callsign} Clear."
if callsign: text += " " + self._get_str('clear', callsign=callsign)
return text
@ -315,9 +404,9 @@ class Narrator:
if callsign_raw:
full_callsign = self.get_full_callsign(loc)
parts.append(f"CQ CQ CQ. This is {full_callsign} with the updated weather report.")
parts.append(self._get_str('intro_cq', callsign=full_callsign))
else:
parts.append("Good day.")
parts.append(self._get_str('intro_good_day'))
# Time Section
if report_config.get('time', True):
@ -333,6 +422,9 @@ class Narrator:
'AKST': 'Alaska Standard Time', 'AKDT': 'Alaska Daylight Time',
'HST': 'Hawaii Standard Time', 'HDT': 'Hawaii Daylight Time'
}
# French TZ map?
# For now keep English TZ names or basic generic.
full_tz = tz_map.get(tz_str, tz_str)
# Attribution
@ -342,7 +434,7 @@ class Narrator:
elif loc.country_code == "CA":
agency = "National Research Council Canada"
parts.append(f"The time is {now_str} {full_tz}. Time provided by the {agency}.")
parts.append(self._get_str('time_msg', time=now_str, tz=full_tz, agency=agency))
# Time Error Section (New Separate)
if report_config.get('time_error', False):
@ -356,10 +448,10 @@ class Narrator:
msg = self.get_time_offset_message(agency_short)
if msg: parts.append(msg + " Break. [PAUSE]")
parts.append(f"This is the automated weather report for {loc.city}, {region_full}. Break. [PAUSE]")
parts.append(self._get_str('automated_report', city=loc.city, region=region_full))
if alerts:
parts.append("Please be advised: " + self.announce_alerts(alerts, loc) + " Break. [PAUSE]")
parts.append(self._get_str('please_advise', alerts=self.announce_alerts(alerts, loc)))
# Conditions
if report_config.get('conditions', True):
@ -388,22 +480,22 @@ class Narrator:
interval = self.config.get('alerts', {}).get('check_interval_minutes', 10)
sev = self.config.get('alerts', {}).get('min_severity', 'Watch')
tone_sev = self.config.get('alerts', {}).get('alert_tone', {}).get('min_severity', 'Warning')
parts.append(f"System status: Monitoring for {sev} level events and higher. Tone alerts active for {tone_sev} events and higher. Monitoring interval is {interval} minutes. Break. [PAUSE]")
parts.append(self._get_str('system_status', sev=sev, tone_sev=tone_sev, interval=interval))
# Code Source
if report_config.get('code_source', False):
parts.append("A S L 3 underscore w x underscore announce is available on github and developed by N 7 X O B as non-commercial code provided for the benefit of the amateur radio community.")
parts.append(self._get_str('code_source'))
# Agency Credits
if loc.country_code == 'US':
parts.append("Data provided by the National Weather Service and the National Institute of Standards and Technology.")
parts.append(self._get_str('data_source_us'))
elif loc.country_code == 'CA':
parts.append("Data provided by Environment Canada, National Research Council Canada, and the National Alert Aggregation and Dissemination System.")
parts.append(self._get_str('data_source_ca'))
parts.append("Break. [PAUSE]")
# Outro
parts.append(f"This concludes the weather report for {loc.city}. {full_callsign} Clear.")
parts.append(self._get_str('concludes', city=loc.city, callsign=full_callsign))
return " ".join(parts)
@ -413,19 +505,10 @@ class Narrator:
else:
callsign = self.config.get('station', {}).get('callsign', 'Amateur Radio')
return (
f"{callsign} Testing. The following is an alerting test of an automated alerting notification and message. "
"Do not take any action as a result of the following message. "
"Repeating - take no action - this is only a test. "
"The test tones will follow in 10 seconds."
)
return self._get_str('test_preamble', callsign=callsign)
def get_test_message(self) -> str:
return (
"This is a test message. "
"This is a sample emergency test message. "
"This is only a test. No action is required."
)
return self._get_str('test_message')
def get_test_postamble(self, loc: LocationInfo = None) -> str:
if loc:
@ -433,15 +516,7 @@ class Narrator:
else:
callsign = self.config.get('station', {}).get('callsign', 'Amateur Radio')
return (
f"{callsign} reminds you this was a test. "
"Do not take action. Repeat - take no action. "
"If this was an actual emergency, the preceding tone would be followed by specific information "
"from official authorities on an imminent emergency that requires your immediate attention "
"and possibly action to prevent loss of life, injury or property damage. "
"This test is concluded."
)
return self._get_str('test_postamble', callsign=callsign)
def get_startup_message(self, loc: LocationInfo, interval: int, active_interval: int, nodes: List[str], source_name: str, hourly_config: dict = None) -> str:
callsign = self.get_full_callsign(loc)
@ -508,39 +583,13 @@ class Narrator:
time_msg = f"The time is {now_str} {full_tz}. {offset_msg}"
msg = (
f"{callsign} Automatic Monitoring Online. "
f"Data provided by {source_name}. "
f"{node_str}"
f"Current location {city}. "
f"{time_msg} "
f"Active Alert Check Interval {interval} minutes. "
f"Dynamic Alert Check Interval {active_interval} minutes. "
)
# Using simpler localization for startup message to avoid overly complex templating
msg = self._get_str('monitor_online', callsign=callsign, source=source_name, nodes=node_str, city=city, time_msg=time_msg, interval=interval, active=active_interval)
if hourly_config and hourly_config.get('enabled', False):
minute = hourly_config.get('minute', 0)
msg += f"Hourly report is on. Hourly notifications will occur at {minute} minutes after the hour. "
content = hourly_config.get('content', {})
enabled_items = []
order = ['time', 'time_error', 'conditions', 'forecast', 'forecast_verbose', 'astro', 'solar_flux', 'status', 'code_source']
for key in order:
if content.get(key, False):
# Special handling for nicer speech
spoken = key.replace('_', ' ')
if key == 'astro': spoken = 'astronomy'
enabled_items.append(spoken)
if enabled_items:
if len(enabled_items) == 1:
msg += f"Content includes {enabled_items[0]}."
else:
# Join with commas, and add "and" before last
joined_content = ", ".join(enabled_items[:-1]) + " and " + enabled_items[-1]
msg += f"Content includes {joined_content}."
# TODO: Localize hourly report content listing if needed
return msg
@ -551,6 +600,6 @@ class Narrator:
callsign = self.config.get('station', {}).get('callsign', 'Station')
if active_alerts:
return f"{callsign} Notification. The monitoring interval is being changed to {interval_mins} minute{'s' if interval_mins!=1 else ''} due to active alerts in the area."
return self._get_str('interval_change_active', callsign=callsign, interval=interval_mins)
else:
return f"{callsign} Notification. Active alerts have expired. The monitoring interval is being changed back to {interval_mins} minutes."
return self._get_str('interval_change_normal', callsign=callsign, interval=interval_mins)

@ -9,11 +9,24 @@ class ECProvider(WeatherProvider):
self.points_cache = {}
self.extra_zones = kwargs.get('alerts', {}).get('extra_zones', [])
self.allowed_events = kwargs.get('alerts', {}).get('ca_events', [])
# Factory passes the full config dict as kwargs
station_cfg = kwargs.get('station', {})
self.language = station_cfg.get('announce_language', 'en')
# Also support direct language kwarg if passed explicitly
if 'language' in kwargs:
self.language = kwargs['language']
# Normalize for EC library (accepts 'english' or 'french')
if self.language in ['fr', 'french']:
self.language = 'french'
else:
self.language = 'english'
def _get_ec_data(self, lat, lon):
# ECWeather auto-selects station based on lat/lon
import asyncio
ec = ECWeather(coordinates=(lat, lon))
ec = ECWeather(coordinates=(lat, lon), language=self.language)
asyncio.run(ec.update())
return ec

@ -9,6 +9,7 @@ location:
station:
# Station ID for announcements
callsign: "MYCALL"
announce_language: "en" # Options: "en", "fr"
hourly_report:
enabled: true
minute: 0 # Run at this minute past the hour (0-59)

Loading…
Cancel
Save

Powered by TurnKey Linux.