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

1import json 

2import math 

3import mimetypes 

4import os 

5import pathlib 

6import shutil 

7import subprocess 

8 

9from django.conf import settings 

10from rest_framework import status 

11from rest_framework.exceptions import APIException 

12 

13import requests 

14import structlog 

15 

16 

17logger = structlog.get_logger(__name__) 

18 

19TMP_DIR = pathlib.Path("/tmp").resolve() 

20UA_STRING = settings.OUTBOUND_USER_AGENT_TEMPLATE.format(purpose="Waveform") 

21 

22 

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" 

27 

28 

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" 

35 

36 

37def ext_from_url(url): 

38 """ 

39 Get the file extension from the given URL. 

40 

41 Looks at the last part of the URL path, and returns the string after the last dot. 

42 

43 :param url: the URL to the file whose extension is being determined 

44 :returns: the file extension or ``None`` 

45 """ 

46 

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 

53 

54 

55def download_audio(url, identifier): 

56 """ 

57 Download the audio from the given URL to a location on the disk. 

58 

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 """ 

63 

64 logger.debug("waveform_audio_download_start", url=url, identifier=identifier) 

65 

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") 

86 

87 return file_name 

88 

89 

90def generate_waveform(file_name: str, duration: int): 

91 """ 

92 Generate the waveform for the file by invoking the ``audiowaveform`` binary. 

93 

94 The Python module ``subprocess`` is used to execute the binary and get the 

95 results that it emits to STDOUT. 

96 

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 """ 

100 

101 logger.debug("waveform_generation_started") 

102 

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) 

123 

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() 

133 

134 logger.debug("waveform_generation_finished", returncode=proc.returncode) 

135 json_out = json.loads(proc.stdout) 

136 return json_out 

137 

138 

139def process_waveform_output(json_out): 

140 """ 

141 Parse the waveform output generated by the ``audiowaveform`` binary. 

142 

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]. 

146 

147 :param json_out: the JSON output generated by ``audiowaveform`` 

148 :returns: the list of peaks 

149 """ 

150 

151 logger.info("Transforming points") 

152 

153 data = json_out["data"] 

154 logger.debug(f"initial points len(data)={len(data)}") 

155 

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 

171 

172 

173def cleanup(file_name): 

174 """ 

175 Delete the audio file after it has been processed. 

176 

177 :param file_name: the name of the file to delete 

178 """ 

179 

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 

192 

193 logger.debug("file deleted") 

194 else: 

195 logger.debug("file not found, nothing deleted") 

196 

197 

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)