Coverage for api/utils/waveform.py: 94%
102 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 06:14 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 06:14 +0000
1import json
2import math
3import mimetypes
4import os
5import pathlib
6import shutil
7import subprocess
9from django.conf import settings
10from rest_framework import status
11from rest_framework.exceptions import APIException
13import requests
14import structlog
17logger = structlog.get_logger(__name__)
19TMP_DIR = pathlib.Path("/tmp").resolve()
20UA_STRING = settings.OUTBOUND_USER_AGENT_TEMPLATE.format(purpose="Waveform")
23class WaveformGenerationFailure(APIException):
24 status_code = status.HTTP_424_FAILED_DEPENDENCY
25 default_detail = "Could not generate the waveform."
26 default_code = "waveform_generation_failure"
29class UpstreamWaveformException(APIException):
30 status_code = status.HTTP_424_FAILED_DEPENDENCY
31 default_detail = (
32 "Could not generate the waveform due to a problem connecting to the provider."
33 )
34 default_code = "upstream_waveform_exception"
37def ext_from_url(url):
38 """
39 Get the file extension from the given URL.
41 Looks at the last part of the URL path, and returns the string after the last dot.
43 :param url: the URL to the file whose extension is being determined
44 :returns: the file extension or ``None``
45 """
47 file_name = url.split("/")[-1]
48 if "." in file_name:
49 ext = file_name.split(".")[-1]
50 return f".{ext}"
51 else:
52 return None
55def download_audio(url, identifier):
56 """
57 Download the audio from the given URL to a location on the disk.
59 :param url: the URL to the file being downloaded
60 :param identifier: the identifier of the media object to name the file
61 :returns: the name of the file on the disk
62 """
64 logger.debug("waveform_audio_download_start", url=url, identifier=identifier)
66 headers = {"User-Agent": UA_STRING}
67 try:
68 with requests.get(url, stream=True, headers=headers) as res:
69 logger.debug(f"res.status_code={res.status_code}")
70 res.raise_for_status()
71 mimetype = res.headers["content-type"]
72 logger.debug(f"mimetype={mimetype}")
73 ext = ext_from_url(url) or mimetypes.guess_extension(mimetype)
74 if ext is None: 74 ↛ 75line 74 didn't jump to line 75 because the condition on line 74 was never true
75 raise ValueError("Could not identify media extension")
76 file_name = f"audio-{identifier}{ext}"
77 logger.debug(f"file name={file_name}")
78 with open(TMP_DIR.joinpath(file_name), "wb") as file:
79 shutil.copyfileobj(res.raw, file)
80 except (requests.RequestException, ValueError) as e:
81 logger.error("waveform_audio_download_failed", exc=e, exc_info=True)
82 if isinstance(e, requests.RequestException): 82 ↛ 85line 82 didn't jump to line 85 because the condition on line 82 was always true
83 raise UpstreamWaveformException()
84 else:
85 raise WaveformGenerationFailure("Unknown file extension")
87 return file_name
90def generate_waveform(file_name: str, duration: int):
91 """
92 Generate the waveform for the file by invoking the ``audiowaveform`` binary.
94 The Python module ``subprocess`` is used to execute the binary and get the
95 results that it emits to STDOUT.
97 :param file_name: the name of the downloaded audio file
98 :param duration: the duration of the audio to determine pixels per second
99 """
101 logger.debug("waveform_generation_started")
103 # Determine the width of the waveform based on the duration of the audio.
104 # The width varies to improve the appearance and "resolution" of the waveform.
105 # It also prevents requesting to many points from short audio files.
106 # See https://github.com/WordPress/openverse/issues/4676
107 #
108 # For long audio files, we set the width to 1,000,000 pixels.
109 # For short audio files, we set the width to 100,000 pixels.
110 # This prevents the waveform from appearing "stretched out" and sparse.
111 width = 1e6 if duration > 100 else 1e5
112 pps = math.ceil(width / duration) # approx 1000 points in total
113 args = [
114 "audiowaveform",
115 "--input-filename",
116 file_name,
117 "--output-format",
118 "json",
119 "--pixels-per-second",
120 str(pps),
121 ]
122 logger.debug("waveform_generation_subprocess", args=args)
124 try:
125 proc = subprocess.run(args, cwd=TMP_DIR, check=True, capture_output=True)
126 except subprocess.CalledProcessError as e:
127 logger.error(
128 "waveform_generation_failed", file_name=file_name, exc=e, exc_info=True
129 )
130 # Do not return details of the exception; we're calling directly to a system binary, and
131 # the command output could be sensitive. Folks debugging can find details in the logs
132 raise WaveformGenerationFailure()
134 logger.debug("waveform_generation_finished", returncode=proc.returncode)
135 json_out = json.loads(proc.stdout)
136 return json_out
139def process_waveform_output(json_out):
140 """
141 Parse the waveform output generated by the ``audiowaveform`` binary.
143 The output consists of alternating positive and negative values, that are almost
144 equal in amplitude. We discard the negative values. We also scale down the
145 amplitudes by the largest value so that they lie in the range [0, 1].
147 :param json_out: the JSON output generated by ``audiowaveform``
148 :returns: the list of peaks
149 """
151 logger.info("Transforming points")
153 data = json_out["data"]
154 logger.debug(f"initial points len(data)={len(data)}")
156 transformed_data = []
157 max_val = 0
158 for idx, val in enumerate(data):
159 if idx % 2 == 0:
160 continue
161 if val < 0: # Any other odd values are negligible and can be ignored
162 val = 0
163 transformed_data.append(val)
164 if val > max_val:
165 max_val = val
166 transformed_data = [round(val / max_val, 5) for val in transformed_data]
167 logger.debug(
168 f"finished transformation len(transformed_data)={len(transformed_data)}"
169 )
170 return transformed_data
173def cleanup(file_name):
174 """
175 Delete the audio file after it has been processed.
177 :param file_name: the name of the file to delete
178 """
180 file_path = TMP_DIR.joinpath(file_name)
181 logger.debug(f"file_path={file_path}")
182 if file_path.exists():
183 logger.debug("deleting file")
184 try:
185 os.remove(file_path)
186 except (OSError, FileNotFoundError) as e:
187 # Do not raise a further exception, because this actually doesn't necessarily mean the request needs to fail
188 logger.error(
189 "waveform_cleanup_failed", exc=e, file_name=file_name, exc_info=True
190 )
191 return
193 logger.debug("file deleted")
194 else:
195 logger.debug("file not found, nothing deleted")
198def generate_peaks(audio) -> list[float]:
199 file_name = None
200 try:
201 file_name = download_audio(audio.url, audio.identifier)
202 awf_out = generate_waveform(file_name, audio.duration)
203 return process_waveform_output(awf_out)
204 finally:
205 if file_name is not None:
206 cleanup(file_name)