audioarxiv 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- audioarxiv/__init__.py +169 -0
- audioarxiv/audio/__init__.py +12 -0
- audioarxiv/audio/base.py +172 -0
- audioarxiv/preprocess/__init__.py +13 -0
- audioarxiv/preprocess/article.py +22 -0
- audioarxiv/preprocess/math_equation.py +36 -0
- audioarxiv/resources/__init__.py +10 -0
- audioarxiv/resources/paper.py +204 -0
- audioarxiv/tools/__init__.py +3 -0
- audioarxiv/tools/main.py +194 -0
- audioarxiv-0.1.0.dist-info/METADATA +117 -0
- audioarxiv-0.1.0.dist-info/RECORD +15 -0
- audioarxiv-0.1.0.dist-info/WHEEL +4 -0
- audioarxiv-0.1.0.dist-info/entry_points.txt +3 -0
- audioarxiv-0.1.0.dist-info/licenses/LICENSE +21 -0
audioarxiv/__init__.py
ADDED
@@ -0,0 +1,169 @@
|
|
1
|
+
"""
|
2
|
+
audioarxiv
|
3
|
+
==========
|
4
|
+
|
5
|
+
audioarxiv is a Python package designed to make staying up to date with the latest research more accessible and
|
6
|
+
convenient.
|
7
|
+
It allows you to fetch research papers directly from `arXiv <https://arxiv.org>`_ and converts them into speech,
|
8
|
+
so you can listen to them on the goβwhether you're commuting, working out, or simply prefer auditory learning.
|
9
|
+
With support for customizable text-to-speech settings and a streamlined interface, audioarxiv offers researchers,
|
10
|
+
students, and enthusiasts a hands-free way to engage with scientific literature.
|
11
|
+
|
12
|
+
**Please note**: the package is still in its early stages of development,
|
13
|
+
and some features may be limited or not fully mature yet.
|
14
|
+
"""
|
15
|
+
from __future__ import annotations
|
16
|
+
|
17
|
+
import json
|
18
|
+
import logging
|
19
|
+
import subprocess
|
20
|
+
import sys
|
21
|
+
from pathlib import Path
|
22
|
+
|
23
|
+
from pandas import DataFrame
|
24
|
+
|
25
|
+
from . import audio, preprocess, resources
|
26
|
+
|
27
|
+
__version__ = "0.1.0"
|
28
|
+
|
29
|
+
|
30
|
+
def get_version_information() -> str:
|
31
|
+
"""Version information.
|
32
|
+
|
33
|
+
Returns:
|
34
|
+
str: Version information.
|
35
|
+
"""
|
36
|
+
return __version__
|
37
|
+
|
38
|
+
|
39
|
+
def setup_logger(logger_: logging.Logger, outdir='.', label=None, log_level='INFO', print_version=False):
|
40
|
+
""" Setup logging output: call at the start of the script to use
|
41
|
+
|
42
|
+
Args:
|
43
|
+
logger_ (logging.Logger): The logger instance to be configured.
|
44
|
+
outdir (str): If supplied, write the logging output to outdir/label.log
|
45
|
+
label (str): If supplied, write the logging output to outdir/label.log
|
46
|
+
log_level (str, optional): ['debug', 'info', 'warning']
|
47
|
+
Either a string from the list above, or an integer as specified
|
48
|
+
in https://docs.python.org/2/library/logging.html#logging-levels
|
49
|
+
print_version (bool): If true, print version information
|
50
|
+
"""
|
51
|
+
if isinstance(log_level, str):
|
52
|
+
try:
|
53
|
+
level = getattr(logging, log_level.upper())
|
54
|
+
except AttributeError as exc:
|
55
|
+
raise ValueError(f'log_level {log_level} not understood') from exc
|
56
|
+
else:
|
57
|
+
level = int(log_level)
|
58
|
+
|
59
|
+
logger_.propagate = False
|
60
|
+
logger_.setLevel(level)
|
61
|
+
|
62
|
+
if not any(isinstance(h, logging.StreamHandler) for h in logger_.handlers):
|
63
|
+
stream_handler = logging.StreamHandler()
|
64
|
+
stream_handler.setFormatter(logging.Formatter(
|
65
|
+
'%(asctime)s %(name)s %(levelname)-8s: %(message)s', datefmt='%H:%M'))
|
66
|
+
stream_handler.setLevel(level)
|
67
|
+
logger_.addHandler(stream_handler)
|
68
|
+
|
69
|
+
if not any(isinstance(h, logging.FileHandler) for h in logger_.handlers):
|
70
|
+
if label:
|
71
|
+
Path(outdir).mkdir(parents=True, exist_ok=True)
|
72
|
+
log_file = f'{outdir}/{label}.log'
|
73
|
+
file_handler = logging.FileHandler(log_file)
|
74
|
+
file_handler.setFormatter(logging.Formatter(
|
75
|
+
'%(asctime)s %(levelname)-8s: %(message)s', datefmt='%H:%M'))
|
76
|
+
|
77
|
+
file_handler.setLevel(level)
|
78
|
+
logger_.addHandler(file_handler)
|
79
|
+
|
80
|
+
for handler in logger_.handlers:
|
81
|
+
handler.setLevel(level)
|
82
|
+
|
83
|
+
if print_version:
|
84
|
+
version = get_version_information()
|
85
|
+
logger_.info('Running audioarxiv version: %s', version)
|
86
|
+
|
87
|
+
|
88
|
+
def loaded_modules_dict() -> dict:
|
89
|
+
"""Get the modules and the versions.
|
90
|
+
|
91
|
+
Returns:
|
92
|
+
dict: A dictionary of the modules and the versions.
|
93
|
+
"""
|
94
|
+
module_names = list(sys.modules.keys())
|
95
|
+
vdict = {}
|
96
|
+
for key in module_names:
|
97
|
+
if "." not in str(key):
|
98
|
+
vdict[key] = str(getattr(sys.modules[key], "__version__", "N/A"))
|
99
|
+
return vdict
|
100
|
+
|
101
|
+
|
102
|
+
def env_package_list(as_dataframe: bool = False) -> list | DataFrame:
|
103
|
+
"""Get the list of packages installed in the system prefix.
|
104
|
+
|
105
|
+
If it is detected that the system prefix is part of a Conda environment,
|
106
|
+
a call to ``conda list --prefix {sys.prefix}`` will be made, otherwise
|
107
|
+
the call will be to ``{sys.executable} -m pip list installed``.
|
108
|
+
|
109
|
+
Args:
|
110
|
+
as_dataframe (bool): return output as a `pandas.DataFrame`
|
111
|
+
|
112
|
+
Returns:
|
113
|
+
Union[list, DataFrame]:
|
114
|
+
If ``as_dataframe=False`` is given, the output is a `list` of `dict`,
|
115
|
+
one for each package, at least with ``'name'`` and ``'version'`` keys
|
116
|
+
(more if `conda` is used).
|
117
|
+
If ``as_dataframe=True`` is given, the output is a `DataFrame`
|
118
|
+
created from the `list` of `dicts`.
|
119
|
+
"""
|
120
|
+
prefix = sys.prefix
|
121
|
+
pkgs = []
|
122
|
+
# if a conda-meta directory exists, this is a conda environment, so
|
123
|
+
# use conda to print the package list
|
124
|
+
conda_detected = (Path(prefix) / "conda-meta").is_dir()
|
125
|
+
if conda_detected:
|
126
|
+
try:
|
127
|
+
pkgs = json.loads(subprocess.check_output([
|
128
|
+
"conda",
|
129
|
+
"list",
|
130
|
+
"--prefix", prefix,
|
131
|
+
"--json"
|
132
|
+
]))
|
133
|
+
except (FileNotFoundError, subprocess.CalledProcessError):
|
134
|
+
# When a conda env is in use but conda is unavailable
|
135
|
+
conda_detected = False
|
136
|
+
|
137
|
+
# otherwise try and use Pip
|
138
|
+
if not conda_detected:
|
139
|
+
try:
|
140
|
+
import pip # noqa: F401 # pylint: disable=unused-import, import-outside-toplevel
|
141
|
+
except ModuleNotFoundError: # no pip?
|
142
|
+
# not a conda environment, and no pip, so just return
|
143
|
+
# the list of loaded modules
|
144
|
+
modules = loaded_modules_dict()
|
145
|
+
pkgs = [{"name": x, "version": y} for x, y in modules.items()]
|
146
|
+
else:
|
147
|
+
pkgs = json.loads(subprocess.check_output([
|
148
|
+
sys.executable,
|
149
|
+
"-m", "pip",
|
150
|
+
"list", "installed",
|
151
|
+
"--format", "json",
|
152
|
+
]))
|
153
|
+
|
154
|
+
# convert to recarray for storage
|
155
|
+
if as_dataframe:
|
156
|
+
return DataFrame(pkgs)
|
157
|
+
return pkgs
|
158
|
+
|
159
|
+
|
160
|
+
logger = logging.getLogger('audioarxiv')
|
161
|
+
setup_logger(logger)
|
162
|
+
|
163
|
+
__all__ = [
|
164
|
+
'audio',
|
165
|
+
'preprocess',
|
166
|
+
'resources',
|
167
|
+
'logger',
|
168
|
+
'__version__'
|
169
|
+
]
|
audioarxiv/audio/base.py
ADDED
@@ -0,0 +1,172 @@
|
|
1
|
+
"""
|
2
|
+
A base class for audio.
|
3
|
+
"""
|
4
|
+
from __future__ import annotations
|
5
|
+
|
6
|
+
import logging
|
7
|
+
import time
|
8
|
+
|
9
|
+
import pyttsx3
|
10
|
+
|
11
|
+
from ..preprocess import get_sentences
|
12
|
+
|
13
|
+
logger = logging.getLogger('audioarxiv')
|
14
|
+
|
15
|
+
|
16
|
+
def validate_audio_arguments(rate: float, volume: float, voice: int | str | None, pause_seconds: float) -> dict:
|
17
|
+
"""Validate the arguments for Audio.
|
18
|
+
|
19
|
+
Args:
|
20
|
+
rate (float): Number of words per minute.
|
21
|
+
volume (float): Volume.
|
22
|
+
voice (int | str | None): If it is int, it is interpreted as the index of the available voices.
|
23
|
+
If it is str, it is interpreted as the ID of the voice.
|
24
|
+
The available voice ids can be found with `list_voices()`.
|
25
|
+
pause_seconds (float): Duration of pause between sentences.
|
26
|
+
|
27
|
+
Returns:
|
28
|
+
dict: rate, volume, voice, pause_seconds
|
29
|
+
"""
|
30
|
+
engine = pyttsx3.init()
|
31
|
+
available_voices = engine.getProperty('voices')
|
32
|
+
rate = max(50, min(500, rate))
|
33
|
+
volume = max(0.0, min(1.0, volume))
|
34
|
+
if isinstance(voice, int):
|
35
|
+
if 0 <= voice < len(available_voices):
|
36
|
+
voice = available_voices[voice].id
|
37
|
+
else:
|
38
|
+
voice = None
|
39
|
+
logger.error('Invalid voice index = %s. Keeping current voice.', voice)
|
40
|
+
elif isinstance(voice, str):
|
41
|
+
if voice not in [v.id for v in available_voices]:
|
42
|
+
voice = None
|
43
|
+
logger.error('Invalid voice ID = %s. Keeping current voice.', voice)
|
44
|
+
elif voice is not None:
|
45
|
+
logger.error('Unsupported datatype of voice = %s. It must be either int or str.', type(voice))
|
46
|
+
if pause_seconds < 0:
|
47
|
+
pause_seconds = 0.1
|
48
|
+
logger.error('pause = %s must be non-negative. Keeping the current pause.', pause_seconds)
|
49
|
+
return {'rate': rate,
|
50
|
+
'volume': volume,
|
51
|
+
'voice': voice,
|
52
|
+
'pause_seconds': pause_seconds}
|
53
|
+
|
54
|
+
|
55
|
+
class Audio:
|
56
|
+
"""A class to generate audio from text.
|
57
|
+
"""
|
58
|
+
def __init__(self, rate: float = 140, # noqa: R0913,E1120,E501 # pylint: disable=too-many-arguments,too-many-positional-arguments,C0301
|
59
|
+
volume: float = 0.9,
|
60
|
+
voice: str | None = None,
|
61
|
+
pause_seconds: float = 0.1,
|
62
|
+
validate_arguments: bool = True):
|
63
|
+
"""A class to configure the audio.
|
64
|
+
|
65
|
+
Args:
|
66
|
+
rate (float, optional): Number of words per minute. Defaults to 140.
|
67
|
+
volume (float, optional): Volume. Defaults to 0.9.
|
68
|
+
voice (Optional[str], optional): Voice id.
|
69
|
+
The available voice ids can be found with `list_voices()`.
|
70
|
+
Defaults to None.
|
71
|
+
pause_seconds (float, optional): Duration of pause between sentences. Defaults to 0.1.
|
72
|
+
validate_arguments (bool): If True, validate the arguments.
|
73
|
+
"""
|
74
|
+
if validate_arguments:
|
75
|
+
arguments = validate_audio_arguments(rate=rate,
|
76
|
+
volume=volume,
|
77
|
+
voice=voice,
|
78
|
+
pause_seconds=pause_seconds)
|
79
|
+
rate = arguments['rate']
|
80
|
+
volume = arguments['volume']
|
81
|
+
voice = arguments['voice']
|
82
|
+
pause_seconds = arguments['pause_seconds']
|
83
|
+
self.engine = pyttsx3.init()
|
84
|
+
if rate is not None:
|
85
|
+
self.engine.setProperty('rate', rate)
|
86
|
+
if volume is not None:
|
87
|
+
self.engine.setProperty('volume', volume)
|
88
|
+
if voice is not None:
|
89
|
+
self.engine.setProperty('voice', voice)
|
90
|
+
self.pause_seconds = pause_seconds
|
91
|
+
|
92
|
+
@property
|
93
|
+
def available_voices(self) -> list:
|
94
|
+
"""Get the available voices.
|
95
|
+
|
96
|
+
Returns:
|
97
|
+
list: The available voices.
|
98
|
+
"""
|
99
|
+
return self.engine.getProperty('voices')
|
100
|
+
|
101
|
+
@property
|
102
|
+
def pause_seconds(self) -> float:
|
103
|
+
"""The duration of pause between sentences.
|
104
|
+
|
105
|
+
Returns:
|
106
|
+
float: Duration of pause between sentences in second.
|
107
|
+
"""
|
108
|
+
return self._pause_seconds
|
109
|
+
|
110
|
+
@pause_seconds.setter
|
111
|
+
def pause_seconds(self, value: float):
|
112
|
+
"""Set the duration of pause between sentences.
|
113
|
+
|
114
|
+
Args:
|
115
|
+
value (float): Duration of pause between sentences.
|
116
|
+
"""
|
117
|
+
if value < 0:
|
118
|
+
logger.error('pause = %s must be non-negative. Keeping the current pause.', value)
|
119
|
+
return
|
120
|
+
self._pause_seconds = value
|
121
|
+
|
122
|
+
def list_voices(self):
|
123
|
+
"""Print available voices with their index and details."""
|
124
|
+
for i, voice in enumerate(self.available_voices):
|
125
|
+
logger.info("Index %s: %s (ID: %s)", i, voice.name, voice.id)
|
126
|
+
|
127
|
+
def clean_text(self, text: str) -> str:
|
128
|
+
"""Clean the text for smoother reading.
|
129
|
+
|
130
|
+
'\\n' is replaced with a white space.
|
131
|
+
|
132
|
+
Args:
|
133
|
+
text (str): Text.
|
134
|
+
|
135
|
+
Returns:
|
136
|
+
str: Cleaned text.
|
137
|
+
"""
|
138
|
+
return " ".join(text.split()).replace('\n', ' ').strip()
|
139
|
+
|
140
|
+
def read_article(self,
|
141
|
+
article: str):
|
142
|
+
"""Read the article aloud, splitting it into sentences.
|
143
|
+
|
144
|
+
Args:
|
145
|
+
article (str): Article.
|
146
|
+
"""
|
147
|
+
if not isinstance(article, str):
|
148
|
+
logger.warning('article = %s is not str. Skipping.', article)
|
149
|
+
return
|
150
|
+
cleaned_text = self.clean_text(article)
|
151
|
+
sentences = get_sentences(cleaned_text)
|
152
|
+
for sentence in sentences:
|
153
|
+
self.engine.say(sentence)
|
154
|
+
self.engine.runAndWait()
|
155
|
+
time.sleep(self.pause_seconds)
|
156
|
+
|
157
|
+
def save_article(self,
|
158
|
+
filename: str,
|
159
|
+
article: str):
|
160
|
+
"""Save the article to an audio file.
|
161
|
+
|
162
|
+
Args:
|
163
|
+
filename (str): File name.
|
164
|
+
article (str): Article.
|
165
|
+
"""
|
166
|
+
cleaned_text = self.clean_text(article)
|
167
|
+
self.engine.save_to_file(cleaned_text, filename)
|
168
|
+
self.engine.runAndWait()
|
169
|
+
|
170
|
+
def stop(self):
|
171
|
+
"""Stop the current speech."""
|
172
|
+
self.engine.stop()
|
@@ -0,0 +1,13 @@
|
|
1
|
+
"""
|
2
|
+
Processes and cleans the extracted text from papers,
|
3
|
+
including sentence segmentation, symbol handling, and formatting for better audio output.
|
4
|
+
"""
|
5
|
+
from __future__ import annotations
|
6
|
+
|
7
|
+
from .article import get_sentences
|
8
|
+
from .math_equation import process_math_equations
|
9
|
+
|
10
|
+
__all__ = [
|
11
|
+
'get_sentences',
|
12
|
+
'process_math_equations',
|
13
|
+
]
|
@@ -0,0 +1,22 @@
|
|
1
|
+
"""
|
2
|
+
Functions to preprocess articles.
|
3
|
+
"""
|
4
|
+
from __future__ import annotations
|
5
|
+
|
6
|
+
import nltk
|
7
|
+
from nltk.tokenize import sent_tokenize
|
8
|
+
|
9
|
+
# Download tokenizer if needed
|
10
|
+
nltk.download('punkt_tab')
|
11
|
+
|
12
|
+
|
13
|
+
def get_sentences(text: str) -> list:
|
14
|
+
"""Get the sentences from the text.
|
15
|
+
|
16
|
+
Args:
|
17
|
+
text (str): Text.
|
18
|
+
|
19
|
+
Returns:
|
20
|
+
list: A list of sentences.
|
21
|
+
"""
|
22
|
+
return sent_tokenize(text)
|
@@ -0,0 +1,36 @@
|
|
1
|
+
"""
|
2
|
+
Functions to preprocess math equations.
|
3
|
+
"""
|
4
|
+
from __future__ import annotations
|
5
|
+
|
6
|
+
import re
|
7
|
+
|
8
|
+
from sympy import srepr
|
9
|
+
from sympy.parsing.sympy_parser import parse_expr
|
10
|
+
|
11
|
+
|
12
|
+
def process_math_equations(text: str) -> str:
|
13
|
+
"""Detects LaTeX-style math symbols and converts them to a readable format.
|
14
|
+
|
15
|
+
Args:
|
16
|
+
text (str): Text.
|
17
|
+
|
18
|
+
Returns:
|
19
|
+
str: Text with the processed math equations.
|
20
|
+
"""
|
21
|
+
|
22
|
+
def replace_math(match: re.Match) -> str:
|
23
|
+
raw_expr = match.group(1)
|
24
|
+
try:
|
25
|
+
parsed = parse_expr(raw_expr)
|
26
|
+
return f"Math: {srepr(parsed)}"
|
27
|
+
except Exception:
|
28
|
+
return f"Equation: {raw_expr}"
|
29
|
+
|
30
|
+
# First replace block math ($$...$$)
|
31
|
+
text = re.sub(r"\$\$(.+?)\$\$", replace_math, text)
|
32
|
+
|
33
|
+
# Then replace inline math, match $...$ only if it's surrounded by non-digit characters (to avoid $5)
|
34
|
+
text = re.sub(r"(?<!\w)\$(.+?)\$(?!\w)", replace_math, text)
|
35
|
+
|
36
|
+
return text
|
@@ -0,0 +1,204 @@
|
|
1
|
+
"""
|
2
|
+
A class to fetch papers from arXiv.
|
3
|
+
"""
|
4
|
+
from __future__ import annotations
|
5
|
+
|
6
|
+
import logging
|
7
|
+
import re
|
8
|
+
import tempfile
|
9
|
+
from datetime import datetime
|
10
|
+
|
11
|
+
import arxiv
|
12
|
+
import fitz
|
13
|
+
|
14
|
+
logger = logging.getLogger('audioarxiv')
|
15
|
+
|
16
|
+
|
17
|
+
def validate_paper_arguments(page_size: int,
|
18
|
+
delay_seconds: float,
|
19
|
+
num_retries: int) -> dict:
|
20
|
+
"""Validate the arguments for Paper.
|
21
|
+
|
22
|
+
Args:
|
23
|
+
page_size (int, optional): Maximum number of results fetched in a single API request. Smaller pages can
|
24
|
+
be retrieved faster, but may require more round-trips. The API's limit is 2000 results per page.
|
25
|
+
Defaults to 100.
|
26
|
+
delay_seconds (float, optional): Number of seconds to wait between API requests.
|
27
|
+
`arXiv's Terms of Use <https://arxiv.org/help/api/tou>`_ ask that you "make no
|
28
|
+
more than one request every three seconds."
|
29
|
+
Defaults to 3.0.
|
30
|
+
num_retries (int, optional): Number of times to retry a failing API request before raising an Exception.
|
31
|
+
Defaults to 3.
|
32
|
+
|
33
|
+
Returns:
|
34
|
+
dict: paper_size, delay_seconds, num_retries
|
35
|
+
"""
|
36
|
+
return {'page_size': page_size,
|
37
|
+
'delay_seconds': delay_seconds,
|
38
|
+
'num_retries': num_retries}
|
39
|
+
|
40
|
+
|
41
|
+
class Paper:
|
42
|
+
"""A class to fetch papers from arXiv.
|
43
|
+
"""
|
44
|
+
def __init__(self, page_size: int = 100, delay_seconds: float = 3.0, num_retries: int = 3,
|
45
|
+
validate_arguments: bool = True):
|
46
|
+
"""An arXiv paper.
|
47
|
+
|
48
|
+
Args:
|
49
|
+
page_size (int, optional): Maximum number of results fetched in a single API request. Smaller pages can
|
50
|
+
be retrieved faster, but may require more round-trips. The API's limit is 2000 results per page.
|
51
|
+
Defaults to 100.
|
52
|
+
delay_seconds (float, optional): Number of seconds to wait between API requests.
|
53
|
+
`arXiv's Terms of Use <https://arxiv.org/help/api/tou>`_ ask that you "make no
|
54
|
+
more than one request every three seconds."
|
55
|
+
Defaults to 3.0.
|
56
|
+
num_retries (int, optional): Number of times to retry a failing API request before raising an Exception.
|
57
|
+
Defaults to 3.
|
58
|
+
validate_arguments (bool, optional): If True, validate the arguments. Defaults to True.
|
59
|
+
"""
|
60
|
+
if validate_arguments:
|
61
|
+
arguments = validate_paper_arguments(page_size=page_size,
|
62
|
+
delay_seconds=delay_seconds,
|
63
|
+
num_retries=num_retries)
|
64
|
+
page_size = arguments['page_size']
|
65
|
+
delay_seconds = arguments['delay_seconds']
|
66
|
+
num_retries = arguments['num_retries']
|
67
|
+
self._client = arxiv.Client(page_size=page_size,
|
68
|
+
delay_seconds=delay_seconds,
|
69
|
+
num_retries=num_retries)
|
70
|
+
self._sections = []
|
71
|
+
self.paper = None
|
72
|
+
|
73
|
+
@property
|
74
|
+
def client(self) -> arxiv.Client:
|
75
|
+
"""Get the arxiv client.
|
76
|
+
|
77
|
+
Returns:
|
78
|
+
arxiv.Client: arxiv client.
|
79
|
+
"""
|
80
|
+
return self._client
|
81
|
+
|
82
|
+
@property
|
83
|
+
def title(self) -> str | None:
|
84
|
+
"""Title of the paper.
|
85
|
+
|
86
|
+
Returns:
|
87
|
+
str | None: Title of the paper. None if paper is None.
|
88
|
+
"""
|
89
|
+
if self.paper is not None:
|
90
|
+
return self.paper.title
|
91
|
+
logger.error('paper is None.')
|
92
|
+
return None
|
93
|
+
|
94
|
+
@property
|
95
|
+
def abstract(self) -> str | None:
|
96
|
+
"""Abstract.
|
97
|
+
|
98
|
+
Returns:
|
99
|
+
str | None: Abstract. None if paper is None.
|
100
|
+
"""
|
101
|
+
if self.paper is not None:
|
102
|
+
return self.paper.summary
|
103
|
+
logger.error('paper is None.')
|
104
|
+
return None
|
105
|
+
|
106
|
+
@property
|
107
|
+
def authors(self) -> list | None:
|
108
|
+
"""List of authors.
|
109
|
+
|
110
|
+
Returns:
|
111
|
+
list | None: List of authors. None if paper is None.
|
112
|
+
"""
|
113
|
+
if self.paper is not None:
|
114
|
+
return [author.name for author in self.paper.authors]
|
115
|
+
logger.error('paper is None.')
|
116
|
+
return None
|
117
|
+
|
118
|
+
@property
|
119
|
+
def published(self) -> datetime | None:
|
120
|
+
"""Published date.
|
121
|
+
|
122
|
+
Returns:
|
123
|
+
datetime: Published date. None if paper is None.
|
124
|
+
"""
|
125
|
+
if self.paper is not None:
|
126
|
+
return self.paper.published
|
127
|
+
logger.error('paper is None.')
|
128
|
+
return None
|
129
|
+
|
130
|
+
@property
|
131
|
+
def updated(self) -> datetime | None:
|
132
|
+
"""Updated date.
|
133
|
+
|
134
|
+
Returns:
|
135
|
+
datetime | None: Updated date. None if paper is None.
|
136
|
+
"""
|
137
|
+
if self.paper is not None:
|
138
|
+
return self.paper.updated
|
139
|
+
logger.error('paper is None.')
|
140
|
+
return None
|
141
|
+
|
142
|
+
def search_by_arxiv_id(self, arxiv_id: str):
|
143
|
+
"""Search paper by arXiv ID.
|
144
|
+
|
145
|
+
Args:
|
146
|
+
arxiv_id (str): arXiv ID.
|
147
|
+
"""
|
148
|
+
self.paper = next(self.client.results(arxiv.Search(id_list=[arxiv_id])))
|
149
|
+
|
150
|
+
def download_pdf(self,
|
151
|
+
dirpath: str = './',
|
152
|
+
filename: str = '') -> str | None:
|
153
|
+
"""Download the PDF.
|
154
|
+
|
155
|
+
Args:
|
156
|
+
dirpath (str, optional): Path to the directory. Defaults to './'.
|
157
|
+
filename (str, optional): Name of the file. Defaults to ''.
|
158
|
+
|
159
|
+
Returns:
|
160
|
+
str | None: Path of the output PDF. None if paper is None.
|
161
|
+
"""
|
162
|
+
if self.paper is not None:
|
163
|
+
return self.paper.download_pdf(dirpath=dirpath, filename=filename)
|
164
|
+
logger.error('Paper is None. Cannot download PDF.')
|
165
|
+
return None
|
166
|
+
|
167
|
+
@property
|
168
|
+
def sections(self) -> list:
|
169
|
+
"""Get the sections of the paper.
|
170
|
+
|
171
|
+
Returns:
|
172
|
+
list: A list of sections. Each section is a dict with the header as the key and the content as the value.
|
173
|
+
"""
|
174
|
+
if len(self._sections) == 0:
|
175
|
+
with tempfile.NamedTemporaryFile() as tmp:
|
176
|
+
filename = tmp.name
|
177
|
+
self.download_pdf(filename=filename)
|
178
|
+
|
179
|
+
doc = fitz.open(filename)
|
180
|
+
|
181
|
+
current_section = {"header": None, "content": []}
|
182
|
+
|
183
|
+
for page in doc:
|
184
|
+
blocks = page.get_text("blocks") # Extract text blocks # type: ignore[attr-defined]
|
185
|
+
|
186
|
+
for block in blocks:
|
187
|
+
text = block[4].strip()
|
188
|
+
|
189
|
+
# Detect section headers using common patterns (uppercase, numbered, bold)
|
190
|
+
if (text.isupper() or re.match(r"^\d+(\.\d+)*\s+\w+", text) or text.endswith(":")):
|
191
|
+
|
192
|
+
# Store previous section before switching
|
193
|
+
if current_section["header"] or current_section["content"]:
|
194
|
+
self._sections.append(current_section)
|
195
|
+
|
196
|
+
current_section = {"header": text, "content": []} # New section
|
197
|
+
else:
|
198
|
+
current_section["content"].append(text)
|
199
|
+
|
200
|
+
# Append the last section
|
201
|
+
if current_section["header"] or current_section["content"]:
|
202
|
+
self._sections.append(current_section)
|
203
|
+
|
204
|
+
return self._sections
|
audioarxiv/tools/main.py
ADDED
@@ -0,0 +1,194 @@
|
|
1
|
+
"""
|
2
|
+
A command line tool to fetch arXiv papers and read aloud.
|
3
|
+
"""
|
4
|
+
from __future__ import annotations
|
5
|
+
|
6
|
+
import json
|
7
|
+
import logging
|
8
|
+
import os
|
9
|
+
import signal
|
10
|
+
import sys
|
11
|
+
import time
|
12
|
+
|
13
|
+
import configargparse
|
14
|
+
from platformdirs import user_config_dir
|
15
|
+
|
16
|
+
from ..audio.base import Audio, validate_audio_arguments
|
17
|
+
from ..resources.paper import Paper, validate_paper_arguments
|
18
|
+
|
19
|
+
logger = logging.getLogger('audioarxiv')
|
20
|
+
|
21
|
+
|
22
|
+
def handle_exit(sig_num: int , frame: object): # noqa: ARG001 # pylint: disable=unused-argument
|
23
|
+
"""Handle the exit.
|
24
|
+
|
25
|
+
Args:
|
26
|
+
sig_num (int): Signal number.
|
27
|
+
frame (object): A frame object.
|
28
|
+
"""
|
29
|
+
logger.info("\nReceived signal %s. Exiting cleanly.", sig_num)
|
30
|
+
sys.exit(0)
|
31
|
+
|
32
|
+
|
33
|
+
def save_settings(config_path: str, settings: dict):
|
34
|
+
"""Save the settings to file.
|
35
|
+
|
36
|
+
Args:
|
37
|
+
config_path (str): Path to the configuration file.
|
38
|
+
settings (dict): Dictionary of the settings.
|
39
|
+
"""
|
40
|
+
try:
|
41
|
+
with open(config_path, 'w', encoding="utf-8") as f:
|
42
|
+
json.dump(settings, f, indent=4)
|
43
|
+
except Exception as e:
|
44
|
+
logger.error('Error saving settings: %s', e)
|
45
|
+
|
46
|
+
|
47
|
+
def initialize_configuration(args: configargparse.Namespace) -> tuple:
|
48
|
+
"""Initialize the configuration.
|
49
|
+
|
50
|
+
Args:
|
51
|
+
args (configargparse.Namespace): Arguments.
|
52
|
+
|
53
|
+
Returns:
|
54
|
+
tuple: settings, config_path
|
55
|
+
"""
|
56
|
+
config_dir = user_config_dir('audioarxiv')
|
57
|
+
os.makedirs(config_dir, exist_ok=True)
|
58
|
+
config_file = 'config.json'
|
59
|
+
config_path = os.path.join(config_dir, config_file)
|
60
|
+
|
61
|
+
# Default settings.
|
62
|
+
settings = {
|
63
|
+
'audio': {
|
64
|
+
'rate': 140,
|
65
|
+
'volume': 0.9,
|
66
|
+
'voice': None,
|
67
|
+
'pause_seconds': 0.1
|
68
|
+
},
|
69
|
+
'paper': {
|
70
|
+
'page_size': 100,
|
71
|
+
'delay_seconds': 3.0,
|
72
|
+
'num_retries': 3
|
73
|
+
}
|
74
|
+
}
|
75
|
+
|
76
|
+
# Validate the default settings.
|
77
|
+
if os.path.exists(config_path):
|
78
|
+
# Load the settings from the config file.
|
79
|
+
try:
|
80
|
+
with open(config_path, encoding="utf-8") as f:
|
81
|
+
loaded_settings = json.load(f)
|
82
|
+
settings.update(loaded_settings)
|
83
|
+
settings['audio'] = validate_audio_arguments(**settings['audio'])
|
84
|
+
settings['paper'] = validate_paper_arguments(**settings['paper'])
|
85
|
+
except Exception as e:
|
86
|
+
logger.error('Error loading settings: %s. Using defaults.', e)
|
87
|
+
else:
|
88
|
+
logger.info('Saving default settings to %s...', config_path)
|
89
|
+
settings['audio'] = validate_audio_arguments(**settings['audio'])
|
90
|
+
settings['paper'] = validate_paper_arguments(**settings['paper'])
|
91
|
+
save_settings(config_path, settings)
|
92
|
+
|
93
|
+
# Check audio properties
|
94
|
+
audio_properties = list(settings['audio'].keys())
|
95
|
+
audio_settings_changed = False
|
96
|
+
for prop in audio_properties:
|
97
|
+
value = getattr(args, prop)
|
98
|
+
if value is not None:
|
99
|
+
# Compare with the existing setting
|
100
|
+
if value != settings['audio'][prop]:
|
101
|
+
settings['audio'][prop] = value
|
102
|
+
audio_settings_changed = True
|
103
|
+
if audio_settings_changed:
|
104
|
+
settings['audio'] = validate_audio_arguments(**settings['audio'])
|
105
|
+
|
106
|
+
# Check paper properties
|
107
|
+
paper_properties = list(settings['paper'].keys())
|
108
|
+
paper_settings_changed = False
|
109
|
+
for prop in paper_properties:
|
110
|
+
value = getattr(args, prop)
|
111
|
+
if value is not None:
|
112
|
+
# Compare with the existing setting
|
113
|
+
if value != settings['paper'][prop]:
|
114
|
+
settings['paper'][prop] = value
|
115
|
+
paper_settings_changed = True
|
116
|
+
if paper_settings_changed:
|
117
|
+
settings['paper'] = validate_paper_arguments(**settings['paper'])
|
118
|
+
|
119
|
+
# Write the settings to file if there are changes.
|
120
|
+
if audio_settings_changed or paper_settings_changed:
|
121
|
+
logger.info('Saving updated settings to %s...', config_path)
|
122
|
+
save_settings(config_path=config_path, settings=settings)
|
123
|
+
return settings, config_path
|
124
|
+
|
125
|
+
|
126
|
+
def main():
|
127
|
+
"""Main function.
|
128
|
+
"""
|
129
|
+
signal.signal(signal.SIGINT, handle_exit)
|
130
|
+
signal.signal(signal.SIGTERM, handle_exit)
|
131
|
+
|
132
|
+
parser = configargparse.ArgParser()
|
133
|
+
parser.add_argument('--id', help='arXiv paper ID.')
|
134
|
+
parser.add_argument('--output', type=str, help='Output to audio file if provided.')
|
135
|
+
parser.add_argument('--rate', type=float, help='Number of words per minute between 50 and 500.')
|
136
|
+
parser.add_argument('--volume', type=float, help='Volume between 0 and 1.')
|
137
|
+
parser.add_argument('--voice', type=str, help='Voice.')
|
138
|
+
parser.add_argument('--pause-seconds', type=float, help='Duration of pause between sentences in second.')
|
139
|
+
parser.add_argument('--page-size', type=int, help='Maximum number of results fetched in a single API request.')
|
140
|
+
parser.add_argument('--delay-seconds', type=float, help='Number of seconds to wait between API requests.')
|
141
|
+
parser.add_argument('--num-retries', type=int, help=('Number of times to retry a failing API request before raising'
|
142
|
+
'an Exception.'))
|
143
|
+
parser.add_argument('--list-voices', action='store_true', help='List the available voices.')
|
144
|
+
|
145
|
+
args = parser.parse_args()
|
146
|
+
|
147
|
+
if args.list_voices:
|
148
|
+
audio = Audio()
|
149
|
+
audio.list_voices()
|
150
|
+
return
|
151
|
+
|
152
|
+
# Get the settings
|
153
|
+
settings, config_path = initialize_configuration(args)
|
154
|
+
|
155
|
+
# The Audio instance.
|
156
|
+
audio = Audio(**settings['audio'])
|
157
|
+
|
158
|
+
# Load the paper.
|
159
|
+
paper = Paper(**settings['paper'])
|
160
|
+
|
161
|
+
# Search the paper.
|
162
|
+
if args.id is not None:
|
163
|
+
# Print the information
|
164
|
+
logger.info('Configuration file: %s', config_path)
|
165
|
+
logger.info('Audio settings')
|
166
|
+
for key, value in settings['audio'].items():
|
167
|
+
logger.info('%s: %s', key, value)
|
168
|
+
|
169
|
+
logger.info('Paper settings')
|
170
|
+
for key, value in settings['paper'].items():
|
171
|
+
logger.info('%s: %s', key, value)
|
172
|
+
|
173
|
+
logger.info('Searching arxiv: %s...', args.id)
|
174
|
+
paper.search_by_arxiv_id(arxiv_id=args.id)
|
175
|
+
# Get the sections
|
176
|
+
sections = paper.sections
|
177
|
+
if args.output is None:
|
178
|
+
for section in sections:
|
179
|
+
audio.read_article(section['header'])
|
180
|
+
time.sleep(1)
|
181
|
+
for content in section['content']:
|
182
|
+
audio.read_article(content)
|
183
|
+
time.sleep(1)
|
184
|
+
else:
|
185
|
+
article = []
|
186
|
+
for section in sections:
|
187
|
+
if section['header'] is not None:
|
188
|
+
article.append(section['header'])
|
189
|
+
if section['content'] is not None:
|
190
|
+
article += section['content']
|
191
|
+
article = " ".join(article)
|
192
|
+
logger.info('Saving audio...')
|
193
|
+
audio.save_article(filename=args.output, article=article)
|
194
|
+
logger.info('Audio is saved to %s.', args.output)
|
@@ -0,0 +1,117 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: audioarxiv
|
3
|
+
Version: 0.1.0
|
4
|
+
Summary: Sample Python Project for creating a new Python Module
|
5
|
+
Author-email: "Isaac C. F. Wong" <isaac.cf.wong@gmail.com>
|
6
|
+
Requires-Python: >=3.9
|
7
|
+
Description-Content-Type: text/markdown
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
9
|
+
Classifier: Intended Audience :: Developers
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
16
|
+
License-File: LICENSE
|
17
|
+
Requires-Dist: configargparse
|
18
|
+
Requires-Dist: arxiv
|
19
|
+
Requires-Dist: pyttsx3
|
20
|
+
Requires-Dist: pymupdf
|
21
|
+
Requires-Dist: sympy
|
22
|
+
Requires-Dist: nltk
|
23
|
+
Requires-Dist: pandas
|
24
|
+
Requires-Dist: platformdirs
|
25
|
+
Requires-Dist: pyspark>=3.0.0 ; extra == "spark"
|
26
|
+
Requires-Dist: bandit[toml]==1.8.3 ; extra == "test"
|
27
|
+
Requires-Dist: black==25.1.0 ; extra == "test"
|
28
|
+
Requires-Dist: check-manifest==0.50 ; extra == "test"
|
29
|
+
Requires-Dist: flake8-bugbear==24.12.12 ; extra == "test"
|
30
|
+
Requires-Dist: flake8-docstrings ; extra == "test"
|
31
|
+
Requires-Dist: flake8-formatter_junit_xml ; extra == "test"
|
32
|
+
Requires-Dist: flake8 ; extra == "test"
|
33
|
+
Requires-Dist: flake8-pyproject ; extra == "test"
|
34
|
+
Requires-Dist: pre-commit==4.2.0 ; extra == "test"
|
35
|
+
Requires-Dist: pylint==3.3.6 ; extra == "test"
|
36
|
+
Requires-Dist: pylint_junit ; extra == "test"
|
37
|
+
Requires-Dist: pytest-cov==6.1.1 ; extra == "test"
|
38
|
+
Requires-Dist: pytest-mock<3.14.1 ; extra == "test"
|
39
|
+
Requires-Dist: pytest-runner ; extra == "test"
|
40
|
+
Requires-Dist: pytest==8.3.5 ; extra == "test"
|
41
|
+
Requires-Dist: pytest-github-actions-annotate-failures ; extra == "test"
|
42
|
+
Requires-Dist: shellcheck-py==0.10.0.1 ; extra == "test"
|
43
|
+
Project-URL: Documentation, https://isaac-cf-wong.github.io/audioarxiv
|
44
|
+
Project-URL: Homepage, https://github.com/isaac-cf-wong/audioarxiv
|
45
|
+
Project-URL: Release Notes, https://github.com/isaac-cf-wong/audioarxiv/releases
|
46
|
+
Project-URL: Source, https://github.com/isaac-cf-wong/audioarxiv
|
47
|
+
Project-URL: Tracker, https://github.com/isaac-cf-wong/audioarxiv/issues
|
48
|
+
Provides-Extra: spark
|
49
|
+
Provides-Extra: test
|
50
|
+
|
51
|
+
# π§ audioarxiv
|
52
|
+
|
53
|
+
[](https://pypi.org/project/audioarxiv/)
|
54
|
+
[](LICENSE)
|
55
|
+
[](https://github.com/isaac-cf-wong/audioarxiv/actions)
|
56
|
+
[](https://codecov.io/gh/isaac-cf-wong/audioarxiv)
|
57
|
+
[](https://pypi.org/project/audioarxiv/)
|
58
|
+
[](https://github.com/PyCQA/bandit)
|
59
|
+
[](https://isaac-cf-wong.github.io/audioarxiv/)
|
60
|
+
|
61
|
+
π **Documentation**: [https://isaac-cf-wong.github.io/audioarxiv/](https://isaac-cf-wong.github.io/audioarxiv/)
|
62
|
+
|
63
|
+
**Turn arXiv papers into audio.**
|
64
|
+
`audioarxiv` lets you fetch the research papers from arXiv and read them aloud.
|
65
|
+
|
66
|
+
---
|
67
|
+
|
68
|
+
## π Features
|
69
|
+
|
70
|
+
- π Search and retrieve papers using the arXiv API
|
71
|
+
- π Extract and parse the content from PDF (excluding title/abstract)
|
72
|
+
- π£οΈ Convert text to speech with natural voice output
|
73
|
+
- π§ Great for passive learning while commuting or doing chores
|
74
|
+
|
75
|
+
---
|
76
|
+
|
77
|
+
## π¦ Installation
|
78
|
+
|
79
|
+
Install from [PyPI](https://pypi.org/project/audioarxiv/):
|
80
|
+
|
81
|
+
```bash
|
82
|
+
pip install audioarxiv
|
83
|
+
```
|
84
|
+
|
85
|
+
Install from [Conda](https://anaconda.org/conda-forge/audioarxiv):
|
86
|
+
|
87
|
+
```bash
|
88
|
+
conda install -c conda-forge audioarxiv
|
89
|
+
```
|
90
|
+
|
91
|
+
---
|
92
|
+
|
93
|
+
## π Usage
|
94
|
+
|
95
|
+
```bash
|
96
|
+
audioarxiv --id "<arxiv id>"
|
97
|
+
```
|
98
|
+
|
99
|
+
### ποΈ Text-to-Speech Options
|
100
|
+
|
101
|
+
You can customize the voice engine using `pyttsx3` by specifying the speaking rate, volume, voice, and pause between sentences.
|
102
|
+
|
103
|
+
```bash
|
104
|
+
audioarxiv --id "<arxiv id>" --rate <rate> --volume <volume> --voice "<voice>" --pause-seconds <pause-seconds>
|
105
|
+
```
|
106
|
+
|
107
|
+
- `rate`: Number of words per minutes. Defaults to 140.
|
108
|
+
- `volume`: Volume of the audio. Defaults to 0.9.
|
109
|
+
- `voice`: Voice of the audio. Defaults to the pyttsx3 default voice.
|
110
|
+
- `pause-seconds`: Number of seconds to pause between sentences.
|
111
|
+
|
112
|
+
The settings are saved, so you only need to provide your preferred settings once.
|
113
|
+
|
114
|
+
## Contributing
|
115
|
+
|
116
|
+
This project welcomes contributions and suggestions. For details, visit the repository's [Contributor License Agreement (CLA)](https://cla.opensource.microsoft.com) and [Code of Conduct](https://opensource.microsoft.com/codeofconduct/) pages.
|
117
|
+
|
@@ -0,0 +1,15 @@
|
|
1
|
+
audioarxiv/__init__.py,sha256=dVC4zN6rZNfTOkPoMVx6Eg68q0u5hk_YrhBnax0-EyE,5796
|
2
|
+
audioarxiv/audio/__init__.py,sha256=UZx3AkhC8NZFXwQbW_sU4sQ1uGeEIRXCPDghXvZy8rY,214
|
3
|
+
audioarxiv/audio/base.py,sha256=tQOby6-12r5lInbAQChMxsQNSQT7_od4_tZ7G8hsDKE,5984
|
4
|
+
audioarxiv/preprocess/__init__.py,sha256=NUCDDLpSwpWTBaPNdLWUVK2FhtwMPsFvXczTE21_UvU,338
|
5
|
+
audioarxiv/preprocess/article.py,sha256=d9nV2DEH4mvKsgUpJ3WB256rt8k5O7YvNTBUp5YOUbs,394
|
6
|
+
audioarxiv/preprocess/math_equation.py,sha256=ulkeMZFJKxU8BH1QzNyW4BZ-UjWfDDMp1C-cqoRGyls,945
|
7
|
+
audioarxiv/resources/__init__.py,sha256=KCZm9Hq0O9oCCtfpyKVDGo_qX-PU2qS8aJe1NgvmR7Q,166
|
8
|
+
audioarxiv/resources/paper.py,sha256=s9XT3xuzntR_0_Np29F66muou0q409jocD4sU2dtgDM,7161
|
9
|
+
audioarxiv/tools/__init__.py,sha256=7X5vtxzvCY9URWo0p3zvM11J6whGFeDPF7XU0dt1Qcw,28
|
10
|
+
audioarxiv/tools/main.py,sha256=sAh7izIdGmfQZiI3tBKn-zLMhQgAQvLGu4OmYnYLsAc,6884
|
11
|
+
audioarxiv-0.1.0.dist-info/entry_points.txt,sha256=d_K6uTNuC8-f9XUQ_enFBgssiK2lVV57EHCEloriVY4,57
|
12
|
+
audioarxiv-0.1.0.dist-info/licenses/LICENSE,sha256=ws_MuBL-SCEBqPBFl9_FqZkaaydIJmxHrJG2parhU4M,1141
|
13
|
+
audioarxiv-0.1.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
14
|
+
audioarxiv-0.1.0.dist-info/METADATA,sha256=9PtyX06fddp0SpU7rHXal8t-RdGywVy7ZwburQaygWQ,4720
|
15
|
+
audioarxiv-0.1.0.dist-info/RECORD,,
|
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) Microsoft Corporation.
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE
|