audiolibrarian 0.16.2__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.
@@ -0,0 +1,259 @@
1
+ """AudioFile support for mp3 files."""
2
+
3
+ #
4
+ # Copyright (c) 2020 Stephen Jibson
5
+ #
6
+ # This file is part of audiolibrarian.
7
+ #
8
+ # Audiolibrarian is free software: you can redistribute it and/or modify it under the terms of the
9
+ # GNU General Public License as published by the Free Software Foundation, either version 3 of the
10
+ # License, or (at your option) any later version.
11
+ #
12
+ # Audiolibrarian is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
13
+ # without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
14
+ # the GNU General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU General Public License along with audiolibrarian.
17
+ # If not, see <https://www.gnu.org/licenses/>.
18
+ #
19
+ from typing import Any, no_type_check
20
+
21
+ import mutagen
22
+ import mutagen.id3
23
+
24
+ from audiolibrarian import audiofile, records
25
+
26
+ APIC = mutagen.id3.APIC
27
+ TXXX = mutagen.id3.TXXX
28
+ UFID = mutagen.id3.UFID
29
+ MB_UFID = "http://musicbrainz.org"
30
+
31
+
32
+ class Mp3File(audiofile.AudioFile, extensions={".mp3"}):
33
+ """AudioFile for MP3 files."""
34
+
35
+ @no_type_check # The mutagen library doesn't provide type hints.
36
+ def read_tags(self) -> records.OneTrack:
37
+ """Read the tags and return a OneTrack object."""
38
+
39
+ def get_l(key: str) -> records.ListF | None:
40
+ if (value := mut.get(key)) is None:
41
+ return None
42
+ if "/" in str(value):
43
+ return records.ListF(str(value).split("/"))
44
+ return records.ListF(value)
45
+
46
+ mut = self._mut_file
47
+ front_cover = None
48
+ if apic := mut.get("APIC:front cover", mut.get("APIC:front", mut.get("APIC:"))):
49
+ front_cover = records.FrontCover(data=apic.data, desc=apic.desc, mime=apic.mime)
50
+ tipl = mut["TIPL"].people if mut.get("TIPL") else []
51
+ roles = ("arranger", "composer", "conductor", "engineer", "mix", "producer", "writer")
52
+ medium_count = int(mut["TPOS"][0].split("/")[1]) if mut.get("TPOS") else None
53
+ medium_number = int(mut["TPOS"][0].split("/")[0]) if mut.get("TPOS") else None
54
+ track_count = int(mut["TRCK"][0].split("/")[1]) if mut.get("TRCK") else None
55
+ track_number = int(mut["TRCK"][0].split("/")[0]) if mut.get("TRCK") else None
56
+ bitrate = mut.info.bitrate // 1000
57
+ bitrate_mode = records.BitrateMode.__members__[
58
+ str(mut.info.bitrate_mode).rsplit(".", maxsplit=1)[-1]
59
+ ]
60
+ # Shortcut for common CBRs.
61
+ if bitrate_mode == records.BitrateMode.UNKNOWN and bitrate in (128, 160, 192, 320):
62
+ bitrate_mode = records.BitrateMode.CBR
63
+ release = (
64
+ records.Release(
65
+ album=mut.get("TALB", [None])[0],
66
+ album_artists=get_l("TPE2"),
67
+ album_artists_sort=get_l("TSO2"),
68
+ asins=get_l("TXXX:ASIN"),
69
+ barcodes=get_l("TXXX:BARCODE"),
70
+ catalog_numbers=get_l("TXXX:CATALOGNUMBER"),
71
+ date=(mut.get("TDRC", [mutagen.id3.ID3TimeStamp("")])[0]).text or None,
72
+ front_cover=front_cover,
73
+ genres=get_l("TCON"),
74
+ labels=get_l("TPUB"),
75
+ media={
76
+ medium_number: records.Medium(
77
+ formats=get_l("TMED"),
78
+ titles=get_l("TSST"),
79
+ track_count=track_count,
80
+ tracks={
81
+ track_number: records.Track(
82
+ artist=mut.get("TPE1", [None])[0],
83
+ artists=get_l("TXXX:ARTISTS"),
84
+ artists_sort=get_l("TSOP"),
85
+ file_info=records.FileInfo(
86
+ bitrate=bitrate,
87
+ bitrate_mode=bitrate_mode,
88
+ path=self.filepath,
89
+ type=records.FileType.MP3,
90
+ ),
91
+ isrcs=get_l("TSRC"),
92
+ musicbrainz_artist_ids=get_l("TXXX:MusicBrainz Artist Id"),
93
+ musicbrainz_release_track_id=mut.get(
94
+ "TXXX:MusicBrainz Release Track Id", [None]
95
+ )[0],
96
+ musicbrainz_track_id=mut.get(
97
+ f"UFID:{MB_UFID}", UFID()
98
+ ).data.decode("utf8")
99
+ or None,
100
+ title=mut.get("TIT2", [None])[0],
101
+ track_number=track_number,
102
+ )
103
+ }
104
+ if track_number
105
+ else None,
106
+ )
107
+ }
108
+ if medium_number
109
+ else None,
110
+ medium_count=medium_count,
111
+ musicbrainz_album_artist_ids=get_l("TXXX:MusicBrainz Album Artist Id"),
112
+ musicbrainz_album_id=mut.get("TXXX:MusicBrainz Album Id", [None])[0],
113
+ musicbrainz_release_group_id=mut.get("TXXX:MusicBrainz Release Group Id", [None])[
114
+ 0
115
+ ],
116
+ original_year=str((mut["TDOR"][0]).year) if mut.get("TDOR") else None,
117
+ people=(
118
+ records.People(
119
+ arrangers=[name for role, name in tipl if role == "arranger"] or None,
120
+ composers=[name for role, name in tipl if role == "composer"] or None,
121
+ conductors=[name for role, name in tipl if role == "conductor"] or None,
122
+ engineers=[name for role, name in tipl if role == "engineer"] or None,
123
+ lyricists=get_l("TEXT"),
124
+ mixers=[name for role, name in tipl if role == "mix"] or None,
125
+ producers=[name for role, name in tipl if role == "producer"] or None,
126
+ performers=[
127
+ records.Performer(name=n, instrument=i)
128
+ for i, n in tipl
129
+ if i not in roles
130
+ ]
131
+ or None,
132
+ writers=[name for role, name in tipl if role == "writer"] or None,
133
+ )
134
+ or None
135
+ ),
136
+ release_countries=get_l("TXXX:MusicBrainz Album Release Country"),
137
+ release_statuses=get_l("TXXX:MusicBrainz Album Status"),
138
+ release_types=get_l("TXXX:MusicBrainz Album Type"),
139
+ script=mut.get("TXXX:SCRIPT", [None])[0],
140
+ )
141
+ or None
142
+ )
143
+ if release:
144
+ release.source = records.Source.TAGS
145
+ return records.OneTrack(
146
+ release=release, medium_number=medium_number, track_number=track_number
147
+ )
148
+
149
+ @no_type_check # The mutagen library doesn't provide type hints.
150
+ def write_tags(self) -> None: # noqa: C901, PLR0912, PLR0915
151
+ """Write the tags."""
152
+
153
+ def slash(text: list[str] | records.ListF) -> str:
154
+ return "/".join(text)
155
+
156
+ release, medium_number, medium, track_number, track = self._get_tag_sources()
157
+ tipl_people = (
158
+ [["arranger", x] for x in (release.people and release.people.arrangers) or []]
159
+ + [["composer", x] for x in (release.people and release.people.composers) or []]
160
+ + [["conductor", x] for x in (release.people and release.people.conductors) or []]
161
+ + [["engineer", x] for x in (release.people and release.people.engineers) or []]
162
+ + [["mix", x] for x in (release.people and release.people.mixers) or []]
163
+ + [["producer", x] for x in (release.people and release.people.producers) or []]
164
+ + [
165
+ [p.instrument, p.name]
166
+ for p in (release.people and release.people.performers) or []
167
+ ]
168
+ + [["writer", x] for x in (release.people and release.people.writers) or []]
169
+ )
170
+ tags: list[mutagen.id3.Frame] = []
171
+ # noinspection PyUnusedLocal
172
+ tag: Any = None
173
+ if tag := release.album:
174
+ tags.append(mutagen.id3.TALB(encoding=1, text=tag))
175
+ if tag := release.people and release.people.composers:
176
+ tags.append(mutagen.id3.TCOM(encoding=1, text=slash(tag)))
177
+ if tag := release.genres:
178
+ tags.append(mutagen.id3.TCON(encoding=3, text=slash(tag)))
179
+ if tag := release.original_year:
180
+ tags.append(mutagen.id3.TDOR(encoding=0, text=str(tag)))
181
+ if tag := release.date:
182
+ tags.append(mutagen.id3.TDRC(encoding=0, text=tag))
183
+ if tag := release.people and release.people.lyricists:
184
+ tags.append(mutagen.id3.TEXT(encoding=1, text=slash(tag)))
185
+ if people := tipl_people:
186
+ tags.append(mutagen.id3.TIPL(encoding=1, people=people))
187
+ if tag := track.title:
188
+ tags.append(mutagen.id3.TIT2(encoding=1, text=tag))
189
+ if tag := medium.formats:
190
+ tags.append(mutagen.id3.TMED(encoding=1, text=slash(tag)))
191
+ if tag := track.artist:
192
+ tags.append(mutagen.id3.TPE1(encoding=1, text=tag))
193
+ if tag := release.album_artists:
194
+ tags.append(mutagen.id3.TPE2(encoding=1, text=slash(tag)))
195
+ if tag := release.people and release.people.conductors:
196
+ tags.append(mutagen.id3.TPE3(encoding=1, text=slash(tag)))
197
+ if (num := medium_number) and (tag := release.medium_count):
198
+ tags.append(mutagen.id3.TPOS(encoding=0, text=f"{num}/{tag}"))
199
+ if tag := release.labels:
200
+ tags.append(mutagen.id3.TPUB(encoding=1, text=slash(tag)))
201
+ if (num := track_number) and (tag := medium.track_count):
202
+ tags.append(mutagen.id3.TRCK(encoding=0, text=f"{num}/{tag}"))
203
+ if tag := release.album_artists_sort:
204
+ tags.append(mutagen.id3.TSO2(encoding=1, text=slash(tag)))
205
+ if tag := track.artists_sort:
206
+ tags.append(mutagen.id3.TSOP(encoding=1, text=slash(tag)))
207
+ if tag := track.isrcs:
208
+ tags.append(mutagen.id3.TSRC(encoding=1, text=slash(tag)))
209
+ if tag := medium.titles:
210
+ tags.append(mutagen.id3.TSST(encoding=1, text=slash(tag)))
211
+ if tag := track.artists:
212
+ tags.append(TXXX(encoding=1, desc="ARTISTS", text=slash(tag)))
213
+ if tag := release.asins:
214
+ tags.append(TXXX(encoding=1, desc="ASIN", text=slash(tag)))
215
+ if tag := release.barcodes:
216
+ tags.append(TXXX(encoding=1, desc="BARCODE", text=slash(tag)))
217
+ if tag := release.catalog_numbers:
218
+ tags.append(TXXX(encoding=1, desc="CATALOGNUMBER", text=slash(tag)))
219
+ if tag := release.musicbrainz_album_artist_ids:
220
+ tags.append(TXXX(encoding=1, desc="MusicBrainz Album Artist Id", text=slash(tag)))
221
+ if tag := release.musicbrainz_album_id:
222
+ tags.append(TXXX(encoding=1, desc="MusicBrainz Album Id", text=tag))
223
+ if tag := release.release_countries:
224
+ tags.append(
225
+ TXXX(encoding=1, desc="MusicBrainz Album Release Country", text=slash(tag))
226
+ )
227
+ if tag := release.release_statuses:
228
+ tags.append(TXXX(encoding=1, desc="MusicBrainz Album Status", text=slash(tag)))
229
+ if tag := release.release_types:
230
+ tags.append(TXXX(encoding=1, desc="MusicBrainz Album Type", text=slash(tag)))
231
+ if tag := track.musicbrainz_artist_ids:
232
+ tags.append(TXXX(encoding=1, desc="MusicBrainz Artist Id", text=slash(tag)))
233
+ if tag := release.musicbrainz_release_group_id:
234
+ tags.append(TXXX(encoding=1, desc="MusicBrainz Release Group Id", text=tag))
235
+ if tag := track.musicbrainz_release_track_id:
236
+ tags.append(TXXX(encoding=1, desc="MusicBrainz Release Track Id", text=tag))
237
+ if tag := release.script:
238
+ tags.append(TXXX(encoding=1, desc="SCRIPT", text=tag))
239
+ if tag := release.original_year:
240
+ tags.append(TXXX(encoding=1, desc="originalyear", text=str(tag)))
241
+ if id_ := track.musicbrainz_track_id:
242
+ tags.append(UFID(owner=MB_UFID, data=bytes(id_, "utf8")))
243
+ if (cover := release.front_cover) is not None:
244
+ tags.append(
245
+ APIC(encoding=0, mime=cover.mime, type=3, desc=cover.desc, data=cover.data)
246
+ )
247
+
248
+ try:
249
+ id3 = mutagen.id3.ID3(self.filepath)
250
+ except mutagen.id3.ID3NoHeaderError:
251
+ self._mut_file.add_tags()
252
+ self._mut_file.save()
253
+ id3 = mutagen.id3.ID3(self.filepath)
254
+
255
+ id3.delete()
256
+ for tag in tags:
257
+ id3.add(tag)
258
+ id3.save()
259
+ self._mut_file = mutagen.File(self.filepath.absolute())
@@ -0,0 +1,48 @@
1
+ """Manage tags."""
2
+
3
+ #
4
+ # Copyright (c) 2020 Stephen Jibson
5
+ #
6
+ # This file is part of audiolibrarian.
7
+ #
8
+ # Audiolibrarian is free software: you can redistribute it and/or modify it under the terms of the
9
+ # GNU General Public License as published by the Free Software Foundation, either version 3 of the
10
+ # License, or (at your option) any later version.
11
+ #
12
+ # Audiolibrarian is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
13
+ # without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
14
+ # the GNU General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU General Public License along with audiolibrarian.
17
+ # If not, see <https://www.gnu.org/licenses/>.
18
+ #
19
+ from typing import Any
20
+
21
+
22
+ # noinspection PyMissingConstructor
23
+ class Tags(dict[Any, Any]):
24
+ """A dict-like object that silently drops keys with None in their values.
25
+
26
+ A key will be dropped if:
27
+ * its value is None
28
+ * its value is a list containing None
29
+ * its value is a dict with None in its values
30
+ """
31
+
32
+ def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401
33
+ """Initialize a Tags object."""
34
+ self.update(*args, **kwargs)
35
+
36
+ def __setitem__(self, k: Any, v: Any) -> None: # noqa: ANN401
37
+ """Set an item only if it should not be dropped."""
38
+ if not (
39
+ v is None
40
+ or (isinstance(v, list) and (None in v or "None" in v))
41
+ or (isinstance(v, dict) and (None in v.values() or "None" in v.values()))
42
+ ):
43
+ super().__setitem__(k, v)
44
+
45
+ def update(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401
46
+ """See base class."""
47
+ for key, value in dict(*args, **kwargs).items():
48
+ self[key] = value
@@ -0,0 +1,215 @@
1
+ """AudioSource."""
2
+
3
+ #
4
+ # Copyright (c) 2020 Stephen Jibson
5
+ #
6
+ # This file is part of audiolibrarian.
7
+ #
8
+ # Audiolibrarian is free software: you can redistribute it and/or modify it under the terms of the
9
+ # GNU General Public License as published by the Free Software Foundation, either version 3 of the
10
+ # License, or (at your option) any later version.
11
+ #
12
+ # Audiolibrarian is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
13
+ # without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
14
+ # the GNU General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU General Public License along with audiolibrarian.
17
+ # If not, see <https://www.gnu.org/licenses/>.
18
+ #
19
+ import abc
20
+ import logging
21
+ import os
22
+ import pathlib
23
+ import shutil
24
+ import subprocess
25
+ import tempfile
26
+ from collections.abc import Callable # noqa: TC003
27
+
28
+ import discid
29
+
30
+ from audiolibrarian import audiofile, records, sh, text
31
+ from audiolibrarian.settings import SETTINGS
32
+
33
+ log = logging.getLogger(__name__)
34
+
35
+
36
+ class AudioSource(abc.ABC):
37
+ """An abstract base class for AudioSource classes."""
38
+
39
+ def __init__(self) -> None:
40
+ """Initialize an AudioSource."""
41
+ self._temp_dir: pathlib.Path = pathlib.Path(tempfile.mkdtemp())
42
+ self._source_list: list[pathlib.Path | None] | None = None
43
+
44
+ def __del__(self) -> None:
45
+ """Remove any temp files."""
46
+ if self._temp_dir.is_dir():
47
+ shutil.rmtree(self._temp_dir)
48
+
49
+ @property
50
+ def source_list(self) -> list[pathlib.Path | None]:
51
+ """Return a list with source file paths and blanks.
52
+
53
+ The list will be ordered by track number, with None in spaces where no
54
+ filename is present for that track number.
55
+ """
56
+ if not self._source_list:
57
+ source_filenames = self.get_source_filenames()
58
+ length = max(text.get_track_number(str(f.name)) for f in source_filenames)
59
+ result: list[pathlib.Path | None] = [None] * length
60
+ if length:
61
+ for filename in source_filenames:
62
+ idx = text.get_track_number(str(filename.name)) - 1
63
+ result[idx] = filename
64
+ self._source_list = result
65
+ return self._source_list
66
+
67
+ def copy_wavs(self, dest_dir: pathlib.Path) -> None:
68
+ """Copy wav files to the given destination directory."""
69
+ for filename in self.get_wav_filenames():
70
+ shutil.copy2(filename, dest_dir / filename.name)
71
+
72
+ def get_front_cover(self) -> records.FrontCover | None:
73
+ """Return a FrontCover record or None."""
74
+ return None
75
+
76
+ @abc.abstractmethod
77
+ def get_search_data(self) -> dict[str, str]:
78
+ """Return a dictionary of search data useful for doing a MusicBrainz search."""
79
+
80
+ @abc.abstractmethod
81
+ def get_source_filenames(self) -> list[pathlib.Path]:
82
+ """Return a list of the original source file paths."""
83
+
84
+ def get_wav_filenames(self) -> list[pathlib.Path]:
85
+ """Return a list of the prepared wav file paths."""
86
+ return sorted(self._temp_dir.glob("*.wav"), key=text.alpha_numeric_key)
87
+
88
+ @abc.abstractmethod
89
+ def prepare_source(self) -> None:
90
+ """Convert the source to wav files."""
91
+
92
+
93
+ class CDAudioSource(AudioSource):
94
+ """AudioSource from a compact disc."""
95
+
96
+ def __init__(self) -> None:
97
+ """Initialize a CDAudioSource."""
98
+ super().__init__()
99
+ self._cd = discid.read(SETTINGS.discid_device, features=["mcn"])
100
+
101
+ def get_search_data(self) -> dict[str, str]:
102
+ """Return a dictionary of search data useful for doing a MusicBrainz search."""
103
+ return {"disc_id": self._cd.id, "disc_mcn": self._cd.mcn}
104
+
105
+ def get_source_filenames(self) -> list[pathlib.Path]:
106
+ """Return a list of the original source file paths.
107
+
108
+ Since we're working with a CD, these files may not yet exist if they have not been
109
+ read from the disc.
110
+ """
111
+ return [
112
+ self._temp_dir / f"track{str(n + 1).zfill(2)}.cdda.wav"
113
+ for n in range(self._cd.last_track_num)
114
+ ]
115
+
116
+ def prepare_source(self) -> None:
117
+ """Pull audio from the CD to wav files."""
118
+ cwd = pathlib.Path.cwd()
119
+ os.chdir(self._temp_dir)
120
+ try:
121
+ subprocess.run(("cd-paranoia", "-B"), check=True) # noqa: S603
122
+ finally:
123
+ os.chdir(cwd)
124
+ subprocess.run(("eject",), check=False) # noqa: S603
125
+
126
+
127
+ class FilesAudioSource(AudioSource):
128
+ """AudioSource from local files."""
129
+
130
+ def __init__(self, filenames: list[pathlib.Path]) -> None:
131
+ """Initialize a FilesAudioSource."""
132
+ super().__init__()
133
+ self._filenames = filenames
134
+ if len(filenames) == 1 and filenames[0].is_dir():
135
+ # If we're given a directory, figure out what's in there.
136
+ for file_type in ("flac", "wav", "m4a", "mp3"):
137
+ if fns := sorted(filenames[0].glob(f"*.{file_type}"), key=text.alpha_numeric_key):
138
+ self._filenames = list(fns)
139
+ break
140
+ self._file_type = self._filenames[0].suffix.lstrip(".")
141
+
142
+ def get_front_cover(self) -> records.FrontCover | None:
143
+ """Return a FrontCover record or None."""
144
+ for filename in self._filenames:
145
+ one_track = audiofile.AudioFile.open(filename).one_track
146
+ release = one_track.release
147
+ if release.front_cover:
148
+ return release.front_cover
149
+ return None
150
+
151
+ def get_search_data(self) -> dict[str, str]:
152
+ """Return a dictionary of search data useful for doing a MusicBrainz search."""
153
+ for filename in self._filenames:
154
+ one_track = audiofile.AudioFile.open(filename).one_track
155
+ release = one_track.release
156
+ track = one_track.track
157
+
158
+ artist = track.artist or track.artists.first or "" if track else ""
159
+ album = release.album or "" if release else ""
160
+ mb_artist_id = (
161
+ release.musicbrainz_album_artist_ids.first
162
+ if release and release.musicbrainz_album_artist_ids
163
+ else "" or track.musicbrainz_artist_ids.first
164
+ if track and track.musicbrainz_artist_ids
165
+ else "" or ""
166
+ )
167
+ mb_release_id = release.musicbrainz_album_id or "" if release else ""
168
+ log.info("Artist from tags: %s", artist)
169
+ log.info("Album from tags: %s", album)
170
+ log.info("MB Artist ID from tags: %s", mb_artist_id)
171
+ log.info("MB Release ID from tags: %s", mb_release_id)
172
+ if mb_artist_id and mb_release_id:
173
+ return {"mb_artist_id": mb_artist_id, "mb_release_id": mb_release_id}
174
+ if artist and album:
175
+ return {"artist": artist, "album": album}
176
+ return {}
177
+
178
+ def get_source_filenames(self) -> list[pathlib.Path]:
179
+ """Return a list of the original source file paths."""
180
+ return self._filenames
181
+
182
+ def prepare_source(self) -> None:
183
+ """Convert the source files to wav files.
184
+
185
+ Raises:
186
+ ValueError if the file type is not supported.
187
+ """
188
+ decoders: dict[str, Callable[[str, str], tuple[str, ...]]] = {
189
+ "flac": lambda i, o: ("flac", "--silent", "--decode", f"--output-name={o}", i),
190
+ "m4a": lambda i, o: ("faad", "-q", "-o", o, i),
191
+ "mp3": lambda i, o: ("mpg123", "-q", "-w", o, i),
192
+ }
193
+ try:
194
+ decode = decoders[self._file_type]
195
+ except KeyError as err:
196
+ msg = f"Unsupported source file type: {self._file_type}"
197
+ raise ValueError(msg) from err
198
+ tmp_dir = self._temp_dir / "__tmp__"
199
+ tmp_dir.mkdir(parents=True)
200
+ commands: list[tuple[str, ...]] = []
201
+ for track_number, filepath in enumerate(self.source_list, 1):
202
+ if filepath:
203
+ in_ = str(filepath)
204
+ out_path = tmp_dir / f"{str(track_number).zfill(2)}__.wav"
205
+ out = str(out_path)
206
+ commands.append(decode(in_, out))
207
+ log.info("DECODING: %s -> %s", filepath.name, out_path.name)
208
+ sh.parallel(f"Making {len(commands)} wav files...", commands)
209
+ sh.touch(tmp_dir.glob("*.wav"))
210
+ for filename in sorted(tmp_dir.glob("*.wav"), key=text.alpha_numeric_key):
211
+ subprocess.run( # noqa: S603
212
+ ("sndfile-convert", "-pcm16", filename, str(filename).replace("/__tmp__/", "/")),
213
+ check=True,
214
+ )
215
+ shutil.rmtree(tmp_dir)