v0.3.0 update

pull/34/head
Mason10198 3 years ago
parent cb0702b482
commit cd7b727277

@ -30,6 +30,8 @@ SkywarnPlus is a sophisticated software solution that works hand-in-hand with yo
- **Pushover Integration:** With Pushover integration, SkywarnPlus can send weather alert notifications directly to your phone or other devices. - **Pushover Integration:** With Pushover integration, SkywarnPlus can send weather alert notifications directly to your phone or other devices.
- **Fault Tolerance:** In the event that SkywarnPlus is unable to access the internet for alert updates (during a severe storm), it will continue to function using alert data it has stored from the last successful data update, using the estimated expiration time provided by the NWS to determine when to automatically "clear" alerts. There is no need to worry about your node "locking up" with stale alerts.
Whether you wish to auto-link to a Skywarn net during severe weather, program your node to control an external device like a siren during a tornado warning, or simply want to stay updated on changing weather conditions, SkywarnPlus offers a comprehensive, efficient, and customizable solution for your weather alert needs. Whether you wish to auto-link to a Skywarn net during severe weather, program your node to control an external device like a siren during a tornado warning, or simply want to stay updated on changing weather conditions, SkywarnPlus offers a comprehensive, efficient, and customizable solution for your weather alert needs.
# Comprehensive Information # Comprehensive Information

@ -1,6 +1,6 @@
#!/usr/bin/python3 #!/usr/bin/python3
# SkyControl.py # SkyControl.py
# A Control Script for SkywarnPlus v0.2.6 # A Control Script for SkywarnPlus v0.3.0
# by Mason Nelson (N5LSN/WRKF394) # by Mason Nelson (N5LSN/WRKF394)
# #
# This script allows you to change the value of specific keys in the SkywarnPlus config.yaml file. # This script allows you to change the value of specific keys in the SkywarnPlus config.yaml file.

@ -1,7 +1,7 @@
#!/usr/bin/python3 #!/usr/bin/python3
""" """
SkyDescribe.py v0.2.6 by Mason Nelson SkyDescribe.py v0.3.0 by Mason Nelson
================================================== ==================================================
Text to Speech conversion for Weather Descriptions Text to Speech conversion for Weather Descriptions
@ -81,7 +81,6 @@ if not api_key:
sys.exit(1) sys.exit(1)
# Main functions
def load_state(): def load_state():
""" """
Load the state from the state file if it exists, else return an initial state. Load the state from the state file if it exists, else return an initial state.
@ -100,6 +99,7 @@ def load_state():
] ]
state["last_alerts"] = OrderedDict(last_alerts) state["last_alerts"] = OrderedDict(last_alerts)
state["last_sayalert"] = state.get("last_sayalert", []) state["last_sayalert"] = state.get("last_sayalert", [])
state["active_alerts"] = state.get("active_alerts", [])
return state return state
else: else:
return { return {
@ -108,6 +108,7 @@ def load_state():
"alertscript_alerts": [], "alertscript_alerts": [],
"last_alerts": OrderedDict(), "last_alerts": OrderedDict(),
"last_sayalert": [], "last_sayalert": [],
"active_alerts": [],
} }
@ -258,19 +259,11 @@ def convert_to_audio(api_key, text):
def main(index_or_title): def main(index_or_title):
"""
The main function of the script.
This function processes the alert, converts it to audio, and plays it using Asterisk.
Args:
index_or_title (str): The index or title of the alert to process.
"""
state = load_state() state = load_state()
alerts = list(state["last_alerts"].items()) # Now alerts is a list of tuples alerts = list(state["last_alerts"].items())
# Determine if the argument is an index or a title # Determine if the argument is an index or a title
try: if str(index_or_title).isdigit():
index = int(index_or_title) - 1 index = int(index_or_title) - 1
if index >= len(alerts): if index >= len(alerts):
logger.error("SkyDescribe: No alert found at index %d.", index + 1) logger.error("SkyDescribe: No alert found at index %d.", index + 1)
@ -278,17 +271,14 @@ def main(index_or_title):
index + 1 index + 1
) )
else: else:
alert, description = alerts[ alert, alert_data = alerts[index]
index (_, description, _) = alert_data
] # Each item in alerts is a tuple: (alert, description) else:
except ValueError:
# Argument is not an index, assume it's a title # Argument is not an index, assume it's a title
title = index_or_title title = index_or_title
for alert, desc in alerts: for alert, alert_data in alerts:
if ( if alert == title: # Assuming alert is a title
alert[0] == title _, description, _ = alert_data
): # Assuming alert is a tuple where the first item is the title
description = desc
break break
else: else:
logger.error("SkyDescribe: No alert with title %s found.", title) logger.error("SkyDescribe: No alert with title %s found.", title)
@ -300,11 +290,11 @@ def main(index_or_title):
# If the description is not an error message, extract the alert title # If the description is not an error message, extract the alert title
if not "Sky Describe error" in description: if not "Sky Describe error" in description:
alert_title = alert[0] # Extract only the title from the alert tuple alert_title = alert # As alert itself is the title now
logger.info("SkyDescribe: Generating description for alert: %s", alert_title) logger.info("SkyDescribe: Generating description for alert: %s", alert_title)
# Add the alert title at the beginning # Add the alert title at the beginning
description = ( description = "Detailed alert information for {}. {}".format(
"Detailed alert information for {}. ".format(alert_title) + description alert_title, description
) )
description = modify_description(description) description = modify_description(description)

@ -1,7 +1,7 @@
#!/usr/bin/python3 #!/usr/bin/python3
""" """
SkywarnPlus v0.2.6 by Mason Nelson (N5LSN/WRKF394) SkywarnPlus v0.3.0 by Mason Nelson (N5LSN/WRKF394)
================================================== ==================================================
SkywarnPlus is a utility that retrieves severe weather alerts from the National SkywarnPlus is a utility that retrieves severe weather alerts from the National
Weather Service and integrates these alerts with an Asterisk/app_rpt based Weather Service and integrates these alerts with an Asterisk/app_rpt based
@ -276,9 +276,6 @@ logger.debug("Tailmessage Blocked events: %s", tailmessage_blocked_events)
def load_state(): def load_state():
""" """
Load the state from the state file if it exists, else return an initial state. Load the state from the state file if it exists, else return an initial state.
Returns:
OrderedDict: A dictionary containing data.
""" """
if os.path.exists(data_file): if os.path.exists(data_file):
with open(data_file, "r") as file: with open(data_file, "r") as file:
@ -307,9 +304,6 @@ def load_state():
def save_state(state): def save_state(state):
""" """
Save the state to the state file. Save the state to the state file.
Args:
state (OrderedDict): A dictionary containing data.
""" """
state["alertscript_alerts"] = list(state["alertscript_alerts"]) state["alertscript_alerts"] = list(state["alertscript_alerts"])
state["last_alerts"] = list(state["last_alerts"].items()) state["last_alerts"] = list(state["last_alerts"].items())
@ -322,13 +316,6 @@ def save_state(state):
def getAlerts(countyCodes): def getAlerts(countyCodes):
""" """
Retrieve severe weather alerts for specified county codes. Retrieve severe weather alerts for specified county codes.
Args:
countyCodes (list): List of county codes.
Returns:
alerts (list): List of active weather alerts.
descriptions (dict): Dictionary of alert descriptions.
""" """
# Mapping for severity for API response and the 'words' severity # Mapping for severity for API response and the 'words' severity
severity_mapping_api = { severity_mapping_api = {
@ -350,17 +337,24 @@ def getAlerts(countyCodes):
logger.debug("getAlerts: DEV Alert Injection Enabled") logger.debug("getAlerts: DEV Alert Injection Enabled")
injected_alerts = config["DEV"].get("INJECTALERTS", []) injected_alerts = config["DEV"].get("INJECTALERTS", [])
logger.debug("getAlerts: Injecting alerts: %s", injected_alerts) logger.debug("getAlerts: Injecting alerts: %s", injected_alerts)
if injected_alerts is None:
injected_alerts = []
for event in injected_alerts: for event in injected_alerts:
last_word = event.split()[-1] last_word = event.split()[-1]
severity = severity_mapping_words.get(last_word, 0) severity = severity_mapping_words.get(last_word, 0)
alerts[(event, severity)] = "Manually injected." description = "This alert was manually injected as a test."
end_time_utc = current_time + timedelta(hours=1)
alerts[(event)] = (
severity,
description,
end_time_utc.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
)
alerts = OrderedDict(list(alerts.items())[:max_alerts]) alerts = OrderedDict(list(alerts.items())[:max_alerts])
return alerts return alerts
# Determing whether to use 'effective' or 'onset' time # Determine whether to use 'effective' or 'onset' time
timetype_mode = config.get("Alerting", {}).get("TimeType", "onset") timetype_mode = config.get("Alerting", {}).get("TimeType", "onset")
if timetype_mode == "effective": if timetype_mode == "effective":
logger.debug("getAlerts: Using effective time for alerting") logger.debug("getAlerts: Using effective time for alerting")
@ -372,11 +366,15 @@ def getAlerts(countyCodes):
time_type_end = "ends" time_type_end = "ends"
for countyCode in countyCodes: for countyCode in countyCodes:
url = "https://api.weather.gov/alerts/active?zone={}".format(countyCode) # url = "https://api.weather.gov/alerts/active?zone={}".format(countyCode)
logger.debug("getAlerts: Checking for alerts in %s at URL: %s", countyCode, url) url = "https://api.weather.gov/alerts/active"
response = requests.get(url) try:
response = requests.get(url)
response.raise_for_status() # will raise an exception if the status code is not 200
if response.status_code == 200: logger.debug(
"getAlerts: Checking for alerts in %s at URL: %s", countyCode, url
)
alert_data = response.json() alert_data = response.json()
for feature in alert_data["features"]: for feature in alert_data["features"]:
start = feature["properties"].get(time_type_start) start = feature["properties"].get(time_type_start)
@ -415,7 +413,11 @@ def getAlerts(countyCodes):
severity = severity_mapping_words.get(last_word, 0) severity = severity_mapping_words.get(last_word, 0)
else: else:
severity = severity_mapping_api.get(severity, 0) severity = severity_mapping_api.get(severity, 0)
alerts[(event, severity)] = description alerts[(event)] = (
severity,
description,
end_time_utc.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
)
seen_alerts.add(event) seen_alerts.add(event)
else: else:
time_difference = time_until(start_time_utc, current_time) time_difference = time_until(start_time_utc, current_time)
@ -431,20 +433,46 @@ def getAlerts(countyCodes):
end_time_utc, end_time_utc,
) )
else: except requests.exceptions.RequestException as e:
logger.error( logger.error("Failed to retrieve alerts for %s. Reason: %s", countyCode, e)
"Failed to retrieve alerts for %s, HTTP status code %s, response: %s", logger.info("API unreachable. Using stored data instead.")
countyCode,
response.status_code, # Load alerts from data.json
response.text, if os.path.isfile(data_file):
) with open(data_file) as f:
data = json.load(f)
stored_alerts = data.get("last_alerts", [])
# Filter alerts by end_time_utc
current_time_str = datetime.now(timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%S.%fZ"
)
logger.debug("Current time: %s", current_time_str)
alerts = {}
for event, alert in stored_alerts:
end_time_str = alert[2]
if parser.parse(end_time_str) >= parser.parse(current_time_str):
logger.debug(
"getAlerts: Keeping %s because it does not expire until %s",
event,
end_time_str,
)
alerts[event] = alert
else:
logger.debug(
"getAlerts: Removing %s because it expired at %s",
event,
end_time_str,
)
else:
logger.error("No stored data available.")
alerts = OrderedDict( alerts = OrderedDict(
sorted( sorted(
alerts.items(), alerts.items(),
key=lambda item: ( key=lambda item: (
item[0][1], # API-provided severity item[1][0], # Severity
severity_mapping_words.get(item[0][0].split()[-1], 0), # Words severity severity_mapping_words.get(item[0].split()[-1], 0), # Words severity
), ),
reverse=True, reverse=True,
) )
@ -459,15 +487,6 @@ def time_until(start_time_utc, current_time):
""" """
Calculate the time difference between two datetime objects and returns it Calculate the time difference between two datetime objects and returns it
as a formatted string. as a formatted string.
Args:
start_time_utc (datetime): The start time of the alert in UTC.
current_time (datetime): The current time in UTC.
Returns:
str: Formatted string displaying the time difference. If the alert has
not started yet, it returns a positive time difference. If the
alert has already started, it returns a negative time difference.
""" """
delta = start_time_utc - current_time delta = start_time_utc - current_time
sign = "-" if delta < timedelta(0) else "" sign = "-" if delta < timedelta(0) else ""
@ -482,15 +501,12 @@ def time_until(start_time_utc, current_time):
def sayAlert(alerts): def sayAlert(alerts):
""" """
Generate and broadcast severe weather alert sounds on Asterisk. Generate and broadcast severe weather alert sounds on Asterisk.
Args:
alerts (OrderedDict): OrderedDict of active weather alerts and their descriptions.
""" """
# Define the path of the alert file # Define the path of the alert file
state = load_state() state = load_state()
# Extract only the alert names from the OrderedDict keys # Extract only the alert names from the OrderedDict keys
alert_names = [alert[0] for alert in alerts.keys()] alert_names = [alert for alert in alerts.keys()]
filtered_alerts = [] filtered_alerts = []
for alert in alert_names: for alert in alert_names:
@ -579,7 +595,7 @@ def sayAlert(alerts):
wait_time = duration + 5 wait_time = duration + 5
logger.info( logger.debug(
"Waiting %s seconds for Asterisk to make announcement to avoid doubling alerts with tailmessage...", "Waiting %s seconds for Asterisk to make announcement to avoid doubling alerts with tailmessage...",
wait_time, wait_time,
) )
@ -610,13 +626,10 @@ def buildTailmessage(alerts):
""" """
Build a tailmessage, which is a short message appended to the end of a Build a tailmessage, which is a short message appended to the end of a
transmission to update on the weather conditions. transmission to update on the weather conditions.
Args:
alerts (list): List of active weather alerts.
""" """
# Extract only the alert names from the OrderedDict keys # Extract only the alert names from the OrderedDict keys
alert_names = [alert[0] for alert in alerts.keys()] alert_names = [alert for alert in alerts.keys()]
# Get the suffix config # Get the suffix config
tailmessage_suffix = config.get("Tailmessage", {}).get("TailmessageSuffix", None) tailmessage_suffix = config.get("Tailmessage", {}).get("TailmessageSuffix", None)
@ -686,18 +699,8 @@ def buildTailmessage(alerts):
def changeCT(ct): def changeCT(ct):
""" """
Change the current Courtesy Tone (CT) to the one specified. Change the current Courtesy Tone (CT) to the one specified.
This function first checks if the specified CT is already in use. If so, it does not make any changes. This function first checks if the specified CT is already in use. If so, it does not make any changes.
If the CT needs to be changed, it replaces the current CT files with the new ones and updates the state file. If the CT needs to be changed, it replaces the current CT files with the new ones and updates the state file.
Args:
ct (str): The name of the new CT to use. This should be one of the CTs specified in the config file.
Returns:
bool: True if the CT was changed, False otherwise.
Raises:
FileNotFoundError: If the specified CT files are not found.
""" """
state = load_state() state = load_state()
current_ct = state["ct"] current_ct = state["ct"]
@ -764,18 +767,8 @@ def changeCT(ct):
def changeID(id): def changeID(id):
""" """
Change the current Identifier (ID) to the one specified. Change the current Identifier (ID) to the one specified.
This function first checks if the specified ID is already in use. If so, it does not make any changes. This function first checks if the specified ID is already in use. If so, it does not make any changes.
If the ID needs to be changed, it replaces the current ID files with the new ones and updates the state file. If the ID needs to be changed, it replaces the current ID files with the new ones and updates the state file.
Args:
id (str): The name of the new ID to use. This should be one of the IDs specified in the config file.
Returns:
bool: True if the ID was changed, False otherwise.
Raises:
FileNotFoundError: If the specified ID files are not found.
""" """
state = load_state() state = load_state()
current_id = state["id"] current_id = state["id"]
@ -825,24 +818,30 @@ def alertScript(alerts):
""" """
This function reads a list of alerts, then performs actions based This function reads a list of alerts, then performs actions based
on the alert triggers defined in the global configuration file. on the alert triggers defined in the global configuration file.
:param alerts: List of alerts to process
:type alerts: list[str]
""" """
# Load the saved state # Load the saved state
state = load_state() state = load_state()
processed_alerts = state["alertscript_alerts"] processed_alerts = set(
active_alerts = state.get("active_alerts", []) # Load active alerts from state state["alertscript_alerts"]
) # Change this to a set for easier processing
active_alerts = set(
state.get("active_alerts", [])
) # Load active alerts from state, also as a set
# Extract only the alert names from the OrderedDict keys # Extract only the alert names from the OrderedDict keys
alert_names = [alert[0] for alert in alerts.keys()] alert_names = set([alert for alert in alerts.keys()]) # This should also be a set
# New alerts are those that are in the current alerts but were not active before # New alerts are those that are in the current alerts but were not active before
new_alerts = list(set(alert_names) - set(active_alerts)) new_alerts = alert_names - active_alerts
# Alerts to be cleared are those that were previously active but are no longer present
cleared_alerts = active_alerts - alert_names
# Update the active alerts in the state # Update the active alerts in the state
state["active_alerts"] = alert_names state["active_alerts"] = list(
alert_names
) # Convert back to list for JSON serialization
# Fetch AlertScript configuration from global_config # Fetch AlertScript configuration from global_config
alertScript_config = config.get("AlertScript", {}) alertScript_config = config.get("AlertScript", {})
@ -854,9 +853,6 @@ def alertScript(alerts):
mappings = [] mappings = []
logger.debug("Mappings: %s", mappings) logger.debug("Mappings: %s", mappings)
# A set to hold alerts that are processed in this run
currently_processed_alerts = set()
# Iterate over each mapping # Iterate over each mapping
for mapping in mappings: for mapping in mappings:
logger.debug("Processing mapping: %s", mapping) logger.debug("Processing mapping: %s", mapping)
@ -888,7 +884,7 @@ def alertScript(alerts):
# Execute commands based on the Type of mapping # Execute commands based on the Type of mapping
for alert in matched_alerts: for alert in matched_alerts:
currently_processed_alerts.add(alert) processed_alerts.add(alert)
if mapping.get("Type") == "BASH": if mapping.get("Type") == "BASH":
logger.debug('Mapping type is "BASH"') logger.debug('Mapping type is "BASH"')
@ -905,25 +901,21 @@ def alertScript(alerts):
) )
subprocess.run(dtmf_cmd, shell=True) subprocess.run(dtmf_cmd, shell=True)
# Clear alerts that are no longer active
processed_alerts -= cleared_alerts
# Update the state with the alerts processed in this run # Update the state with the alerts processed in this run
state["alertscript_alerts"] = list(currently_processed_alerts) state["alertscript_alerts"] = list(
processed_alerts
) # Convert back to list for JSON serialization
save_state(state) save_state(state)
def sendPushover(message, title=None, priority=0): def sendPushover(message, title=None, priority=0):
""" """
Send a push notification via the Pushover service. Send a push notification via the Pushover service.
This function constructs the payload for the request, including the user key, API token, message, title, and priority. This function constructs the payload for the request, including the user key, API token, message, title, and priority.
The payload is then sent to the Pushover API endpoint. If the request fails, an error message is logged. The payload is then sent to the Pushover API endpoint. If the request fails, an error message is logged.
Args:
message (str): The content of the push notification.
title (str, optional): The title of the push notification. Defaults to None.
priority (int, optional): The priority of the push notification. Defaults to 0.
Raises:
requests.exceptions.HTTPError: If an error occurs while sending the notification.
""" """
pushover_config = config["Pushover"] pushover_config = config["Pushover"]
user_key = pushover_config.get("UserKey") user_key = pushover_config.get("UserKey")
@ -950,12 +942,6 @@ def sendPushover(message, title=None, priority=0):
def convertAudio(audio): def convertAudio(audio):
""" """
Convert audio file to 8000Hz mono for compatibility with Asterisk. Convert audio file to 8000Hz mono for compatibility with Asterisk.
Args:
audio (AudioSegment): Audio file to be converted.
Returns:
AudioSegment: Converted audio file.
""" """
return audio.set_frame_rate(8000).set_channels(1) return audio.set_frame_rate(8000).set_channels(1)
@ -970,14 +956,6 @@ def change_and_log_CT_or_ID(
): ):
""" """
Check whether the CT or ID needs to be changed, performs the change, and logs the process. Check whether the CT or ID needs to be changed, performs the change, and logs the process.
Args:
alerts (list): The new alerts that have been fetched.
specified_alerts (list): The alerts that require a change in CT or ID.
auto_change_enabled (bool): Whether auto change is enabled for CT or ID.
alert_type (str): "CT" for Courtesy Tones and "ID" for Identifiers.
pushover_debug (bool): Whether to include debug information in pushover notifications.
pushover_message (str): The current pushover message to which any updates will be added.
""" """
if auto_change_enabled: if auto_change_enabled:
logger.debug( logger.debug(
@ -988,7 +966,7 @@ def change_and_log_CT_or_ID(
) )
# Extract only the alert names from the OrderedDict keys # Extract only the alert names from the OrderedDict keys
alert_names = [alert[0] for alert in alerts.keys()] alert_names = [alert for alert in alerts.keys()]
# Check if any alert matches specified_alerts # Check if any alert matches specified_alerts
# Here we replace set intersection with a list comprehension # Here we replace set intersection with a list comprehension
@ -1021,9 +999,6 @@ def change_and_log_CT_or_ID(
def supermon_back_compat(alerts): def supermon_back_compat(alerts):
""" """
Write alerts to a file for backward compatibility with supermon. Write alerts to a file for backward compatibility with supermon.
Args:
alerts (OrderedDict): The alerts to write.
""" """
# Ensure the target directory exists # Ensure the target directory exists
@ -1058,9 +1033,23 @@ def main():
alerts = getAlerts(countyCodes) alerts = getAlerts(countyCodes)
# If new alerts differ from old ones, process new alerts # If new alerts differ from old ones, process new alerts
if [alert[0] for alert in last_alerts.keys()] != [
alert[0] for alert in alerts.keys()
]:
added_alerts = [
alert for alert in alerts.keys() if alert not in last_alerts.keys()
]
removed_alerts = [
alert for alert in last_alerts.keys() if alert not in alerts.keys()
]
if added_alerts:
logger.info("Alerts added: %s", ", ".join(alert for alert in added_alerts))
if removed_alerts:
logger.info(
"Alerts removed: %s", ", ".join(alert for alert in removed_alerts)
)
if last_alerts.keys() != alerts.keys():
new_alerts = [x for x in alerts.keys() if x not in last_alerts.keys()]
state["last_alerts"] = alerts state["last_alerts"] = alerts
save_state(state) save_state(state)
@ -1102,17 +1091,17 @@ def main():
pushover_message, pushover_message,
) )
if alertscript_enabled:
alertScript(alerts)
# Check if alerts need to be communicated # Check if alerts need to be communicated
if len(alerts) == 0: if len(alerts) == 0:
logger.info("Alerts cleared") logger.info("Alerts cleared")
if say_all_clear_enabled: if say_all_clear_enabled:
sayAllClear() sayAllClear()
else: else:
logger.info("New alerts: %s", new_alerts)
if say_alert_enabled: if say_alert_enabled:
sayAlert(alerts) sayAlert(alerts)
if alertscript_enabled:
alertScript(alerts)
# Check if tailmessage needs to be built # Check if tailmessage needs to be built
enable_tailmessage = config.get("Tailmessage", {}).get("Enable", False) enable_tailmessage = config.get("Tailmessage", {}).get("Enable", False)

@ -1,4 +1,4 @@
# SkywarnPlus v0.2.6 Configuration File # SkywarnPlus v0.3.0 Configuration File
# Author: Mason Nelson (N5LSN/WRKF394) # Author: Mason Nelson (N5LSN/WRKF394)
# Please edit this file according to your specific requirements. # Please edit this file according to your specific requirements.
@ -55,7 +55,7 @@ Alerting:
# Limit the maximum number of alerts to process in case of multiple alerts. # Limit the maximum number of alerts to process in case of multiple alerts.
# SkywarnPlus fetches all alerts, orders them by severity, and processes only the 'n' most severe alerts, where 'n' is the MaxAlerts value. # SkywarnPlus fetches all alerts, orders them by severity, and processes only the 'n' most severe alerts, where 'n' is the MaxAlerts value.
#MaxAlerts: #MaxAlerts: 5
# Specify an alternative path to the directory where sound files are located. # Specify an alternative path to the directory where sound files are located.
# Default is SkywarnPlus/SOUNDS. # Default is SkywarnPlus/SOUNDS.
@ -244,13 +244,13 @@ SkyDescribe:
# See VoiceRSS.ors/api/ for more information # See VoiceRSS.ors/api/ for more information
# API Key for VoiceRSS.org # API Key for VoiceRSS.org
APIKey: APIKey:
# VoiceRSS language code # VoiceRSS language code
Language: en-us Language: en-us
# VoiceRSS speech rate. -10 is slowest, 10 is fastest. # VoiceRSS speech rate. -10 is slowest, 10 is fastest.
Speed: 0 Speed: 1
# VoiceRSS voice profile. See VoiceRSS.org/api/ for more information. # VoiceRSS voice profile. See VoiceRSS.org/api/ for more information.
Voice: John Voice: John

Loading…
Cancel
Save

Powered by TurnKey Linux.