audioarxiv 0.1.0rc46.post1__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 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-rc46-post1"
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
+ ]
@@ -0,0 +1,12 @@
1
+ """
2
+ Handles text-to-speech conversion using various engines to read research papers aloud.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from . import base
7
+ from .base import Audio
8
+
9
+ __all__ = [
10
+ 'base',
11
+ 'Audio',
12
+ ]
@@ -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,10 @@
1
+ """
2
+ Fetches research papers and metadata from arXiv using the arXiv API.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from .paper import Paper
7
+
8
+ __all__ = [
9
+ 'Paper',
10
+ ]
@@ -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
@@ -0,0 +1,3 @@
1
+ """
2
+ Command line tools.
3
+ """
@@ -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,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: audioarxiv
3
+ Version: 0.1.0rc46.post1
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: Source, https://github.com/isaac-cf-wong/audioarxiv
45
+ Project-URL: Tracker, https://github.com/isaac-cf-wong/audioarxiv/issues
46
+ Provides-Extra: spark
47
+ Provides-Extra: test
48
+
49
+ # 🎧 audioarxiv
50
+
51
+ [![PyPI version](https://badge.fury.io/py/audioarxiv.svg)](https://pypi.org/project/audioarxiv/)
52
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
53
+ [![Build](https://img.shields.io/github/actions/workflow/status/isaac-cf-wong/audioarxiv/CI.yml?branch=main)](https://github.com/isaac-cf-wong/audioarxiv/actions)
54
+ [![Python Version](https://img.shields.io/pypi/pyversions/audioarxiv)](https://pypi.org/project/audioarxiv/)
55
+ [![Security: bandit](https://img.shields.io/badge/security-bandit-yellow.svg)](https://github.com/PyCQA/bandit)
56
+ [![Documentation Status](https://img.shields.io/badge/documentation-online-brightgreen)](https://isaac-cf-wong.github.io/audioarxiv/)
57
+
58
+ πŸ“š **Documentation**: [https://isaac-cf-wong.github.io/audioarxiv/](https://isaac-cf-wong.github.io/audioarxiv/)
59
+
60
+ **Turn arXiv papers into audio.**
61
+ `audioarxiv` lets you fetch the research papers from arXiv and read them aloud.
62
+
63
+ ---
64
+
65
+ ## πŸš€ Features
66
+
67
+ - πŸ” Search and retrieve papers using the arXiv API
68
+ - πŸ“„ Extract and parse the content from PDF (excluding title/abstract)
69
+ - πŸ—£οΈ Convert text to speech with natural voice output
70
+ - 🧠 Great for passive learning while commuting or doing chores
71
+
72
+ ---
73
+
74
+ ## πŸ“¦ Installation
75
+
76
+ Install from [PyPI](https://pypi.org/project/audioarxiv/):
77
+
78
+ ```bash
79
+ pip install audioarxiv
80
+ ```
81
+
82
+ Install from [Conda](https://anaconda.org/conda-forge/audioarxiv):
83
+
84
+ ```bash
85
+ conda install -c conda-forge audioarxiv
86
+ ```
87
+
88
+ ---
89
+
90
+ ## πŸ›  Usage
91
+
92
+ ```bash
93
+ audioarxiv --id "<arxiv id>"
94
+ ```
95
+
96
+ ### πŸŽ™οΈ Text-to-Speech Options
97
+
98
+ You can customize the voice engine using `pyttsx3` by specifying the speaking rate, volume, voice, and pause between sentences.
99
+
100
+ ```bash
101
+ audioarxiv --id "<arxiv id>" --rate <rate> --volume <volume> --voice "<voice>" --pause-seconds <pause-seconds>
102
+ ```
103
+
104
+ - `rate`: Number of words per minutes. Defaults to 140.
105
+ - `volume`: Volume of the audio. Defaults to 0.9.
106
+ - `voice`: Voice of the audio. Defaults to the pyttsx3 default voice.
107
+ - `pause-seconds`: Number of seconds to pause between sentences.
108
+
109
+ The settings are saved, so you only need to provide your preferred settings once.
110
+
111
+ ## Contributing
112
+
113
+ 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.
114
+
@@ -0,0 +1,15 @@
1
+ audioarxiv/__init__.py,sha256=XIOQIggB9Lhdm1TolfKniYg7pEHZNnz4WqChpVcPaw0,5807
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.0rc46.post1.dist-info/entry_points.txt,sha256=d_K6uTNuC8-f9XUQ_enFBgssiK2lVV57EHCEloriVY4,57
12
+ audioarxiv-0.1.0rc46.post1.dist-info/licenses/LICENSE,sha256=ws_MuBL-SCEBqPBFl9_FqZkaaydIJmxHrJG2parhU4M,1141
13
+ audioarxiv-0.1.0rc46.post1.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
14
+ audioarxiv-0.1.0rc46.post1.dist-info/METADATA,sha256=OJI4px0ymhXa8WIUf3cse9LusAcXqCKAlQmqfUHJb_o,4445
15
+ audioarxiv-0.1.0rc46.post1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: flit 3.12.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ audioarxiv=audioarxiv.tools.main:main
3
+
@@ -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