-
Notifications
You must be signed in to change notification settings - Fork 0
/
applyParamsToWavs.py
executable file
·425 lines (316 loc) · 14.1 KB
/
applyParamsToWavs.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
#!/bin/env python3
# -*- coding: utf-8 -*-
import logging
import os
import re
import time
import glob
import math
import json
import subprocess
from shutil import copyfile, copytree
from pathlib import Path
# the directory with all textfile-recordings generated by `ui24r-paramrecorder.py`
paramRecordingsDir = None
# the storage that was attached to the Ui24r during recording. usually `<storage-path>/Multitrack`
audioRecordingsDir = None
# the directory where the post processed audio files gets written to
outputRootDir = None
# the directory for the track within the outputRootDir
outputDir = None
# combine stereoLinked channels to a stereo file? (Ui24R ignores stereoLink when creating audio recorings)
convertMonoToStereo = True
paramRecFile = None
applyFilterFile = None
allParams = []
class Param(object):
def __init__(self, paramList):
self.time = float(paramList[0])
self.name = str(paramList[1])
self.value = paramList[2]
def runStuff():
global recPath, allParams, paramRecordingsDir, audioRecordingsDir, outputRootDir, applyFilterFile
paramRecordingsDir = Path(
"%s/recordings" % os.path.dirname(os.path.abspath(__file__))
)
audioRecordingsDir = Path(
"%s/recordings" % os.path.dirname(os.path.abspath(__file__))
)
outputRootDir = Path(
"%s/processedAudio" % os.path.dirname(os.path.abspath(__file__))
)
applyFilterFile = Path(
"%s/recordings/tmp_applyFilter.txt" % os.path.dirname(os.path.abspath(__file__))
)
findAndProcessInputPairs()
print("Done! hopefully...")
'''
marry audio recording directory with paramsRecordings file
'''
def findAndProcessInputPairs():
global allParams, paramRecordingsDir, audioRecordingsDir, paramRecFile, applyFilterFile, outputRootDir, outputDir
allRecoringDirectories = [f.path for f in os.scandir(str(audioRecordingsDir)) if f.is_dir()]
for trackDir in allRecoringDirectories:
sessionName = Path(trackDir).stem
trackConfigFile = Path("%s/.uirecsession" % trackDir)
outputDir = Path("%s/%s-processed" % (str(outputRootDir), sessionName))
outputDir.mkdir(parents=True, exist_ok=True)
if not trackConfigFile.is_file():
print("WARNING: cant find .uirecsession file for session dir")
print(" TODO: does it make sense to simply copy the directory to target?")
continue
copyfile(str(trackConfigFile), ('%s/.uirecsession'% str(outputDir)))
trackConfigJson = json.loads(getFileContent( str(trackConfigFile) ) )
# used for possible mono/stereo merge
previousChannelIndex = -1
previousAudioFileName = ""
for key, channelString in enumerate(trackConfigJson["mapping"]):
channelIndex = int(re.sub("[^0-9]", "", channelString))
audioFileName = trackConfigJson["files"][key] + trackConfigJson["ext"]
paramRecordingFile = searchParamRecordingsFileNameForSession(sessionName)
if not paramRecordingFile:
print("WARNING: cant find params recordings file for audio track")
print(" TODO: does it make sense to simply copy the audio file to target?")
continue
paramRecFile = Path(paramRecordingFile)
allParams = getFileContent(str(paramRecFile)).split('\n')
audioInputFile = str(Path(trackDir + '/' + audioFileName))
audioOutputFile = Path("%s/%s" % (str(outputDir), audioFileName))
filteredParams = grabVolumeParametersForInput(channelIndex)
filterLines = convertVolumeParamsToFilterArguments(filteredParams, channelIndex)
applyFilterFile.write_text(',\n'.join(filterLines))
applyFilter(audioInputFile, applyFilterFile, audioOutputFile)
checkMergeMonoToStereo(channelIndex, previousChannelIndex, audioFileName, previousAudioFileName)
previousChannelIndex = channelIndex
previousAudioFileName = audioFileName
def checkMergeMonoToStereo(channelIndex, previousChannelIndex, audioFileName, previousAudioFileName):
global allParams
if not convertMonoToStereo:
# disabled by configuration. nothing to do
return
if previousChannelIndex == -1:
# we havn't processed mergeable files yet
return
if channelIndex - previousChannelIndex != 1:
# Ui24R can only activate stereoLink on channels next to each other
return
if previousChannelIndex % 2 != 0:
# Ui24R can only have linked even channelIndex as [LEFT] to odd channelIndex [RIGHT] (index starts from 0)
return
if not stereoLinkEnabled(previousChannelIndex, channelIndex):
# stereoLink settings has not been enabled during param recording
return
# ok, fire up ffmpeg for merging
leftChannelFile = Path("%s/%s" % (str(outputDir), previousAudioFileName))
rightChannelFile = Path("%s/%s" % (str(outputDir), audioFileName))
stereoTargetFile = Path("%s/%s.%s.stereo.wav" % (str(outputDir), previousAudioFileName, audioFileName))
mergeMonoToStereo(leftChannelFile, rightChannelFile, stereoTargetFile)
# delete single channel files
os.unlink(str(leftChannelFile))
os.unlink(str(rightChannelFile))
def stereoLinkEnabled(leftIndex, rightIndex):
leftChannelStereoLink = False
rightChannelStereoLink = False
paramNameWhitelist = [
"i.%i.stereoIndex" % leftIndex,
"i.%i.stereoIndex" % rightIndex
]
for paramLine in allParams:
paramList = paramLine.split(' ' ,2)
if len(paramList) != 3 or paramList[1] not in paramNameWhitelist:
continue
if paramList[1] == "i.%i.stereoIndex" % leftIndex and paramList[2] == "0":
leftChannelStereoLink = True
if paramList[1] == "i.%i.stereoIndex" % rightIndex and paramList[2] == "1":
rightChannelStereoLink = True
return leftChannelStereoLink and rightChannelStereoLink
def searchParamRecordingsFileNameForSession(sessionKey):
global paramRecordingsDir
return guessBestParamRecordingsFile(
glob.glob(
"%s/*-recsession-%s.uiparamrecording.txt" % (
str(paramRecordingsDir),
sessionKey
)
)
)
def guessBestParamRecordingsFile(foundFiles):
if len(foundFiles) == 0:
return None
if len(foundFiles) == 1:
# nothing to guess. its very likely that we found the matching param recording
return foundFiles.pop()
# TODO: theoretically we can have multiple paramRecordings with this key as its a number between 0000 and 9999
# we are able to compare fileName with "i.<index>.name" and/or audio file duration with paramRecording duration
# probably the very last file is the latest and most relevant file?
# for now give a shit on this edge case and return the last found file
foundFiles.sort()
return foundFiles.pop()
'''
convertVolumeParamsToFilterArguments()
based on volume relevant parameters we have to build audio filter settings for ffmpeg
@param list filteredParams: a list with paramater instances. already filtered for a single input channel
@param int currentInputIndex: the inputIndex of the current channel
@return list the list with the actual audio filter syntax for ffmpeg
'''
def convertVolumeParamsToFilterArguments(filteredParams, currentInputIndex):
# the list that gets returned by this method
filterLines = []
# helper var to maybe ignore volume changes
currentlyMuted = "0"
# helper vars to track whats already persisted as a filter argument
lastPersistedEndtime = 0
lastPersistedVolume = 0
# actual volume may gets overriden because of mute
volumeToCheck = 0
lastCheckedVolume = 0
# after unmuting we have to apply the last tracked volume again
lastTrackedVolume = 0
# loop over all params and apply the volume value as soon as it changes
for param in filteredParams:
if param.name == "i.%i.mix" % currentInputIndex:
volumeToCheck = param.value
lastTrackedVolume = param.value
if param.name == "i.%i.mute" % currentInputIndex:
currentlyMuted = param.value
if param.value == '0':
volumeToCheck = lastTrackedVolume
if currentlyMuted == '1':
volumeToCheck = 0
if lastPersistedVolume != volumeToCheck and param.time > 0:
filterLines.append(
"volume=enable='between(t,%s,%s)':volume='%s':eval=frame" % (
lastPersistedEndtime,
param.time,
convertVolumeValue(lastCheckedVolume)
)
)
lastPersistedEndtime = param.time
lastPersistedVolume = volumeToCheck
lastCheckedVolume = volumeToCheck
# apply the very last line until end position of the audio file.
filterLines.append(
"volume=enable='between(t,%s,%s)':volume='%s':eval=frame" % (
lastPersistedEndtime,
getEndPosition(),
convertVolumeValue(lastCheckedVolume)
)
)
return filterLines
'''
WARNING: current return value is FAKE
@see comments of the 3 possibilities
'''
def getEndPosition():
# possibility 1: read duration from input file via ffprobe
# duration = detectDuration(inputFile)
# possibility 2: read duration from json created by Ui24R
# duration = parse file .uirecsession for key "lengthSeconds"
# possibility 3: return a very high number
# as an out-of-range value seems to be no problem for ffmpeg go with this fastest approach
# hopefully 100000 seconds is higher than the actual length of the audio file
return 100000
'''
currently not used
@see comment above regarding out-of-range duration
'''
def detectDuration(filePath):
cmd = [
'ffprobe', '-i', str(filePath),
'-show_entries', 'format=duration',
'-v', 'quiet', '-of', 'csv=p=0'
]
processStdOut = generalCmd(cmd, 'detect duration')
return float(processStdOut.strip())
'''
filterParamsForInput()
pick only the params that are relevant for the single audio file to process
defined by the index of the input
currently only "i.<inputIndex>.mix" and "i.<inputIndex>.mute" gets processed
@TODO: we should also take a look onto "i.<all-other-inputs>.solo" because this will cause silence for this track as well
@param int inputIndex the index of the audio input [0-22]
@return list a list of Parameter instances
'''
def grabVolumeParametersForInput(inputIndex):
paramsForInput = []
# define all params that affects our audio processing for this single input
paramNameWhitelist = [
"i.%i.mix" % inputIndex,
"i.%i.mute" % inputIndex
]
for paramLine in allParams:
paramList = paramLine.split(' ' ,2)
if len(paramList) != 3 or paramList[1] not in paramNameWhitelist:
continue
paramsForInput.append(Param(paramList))
return paramsForInput
def getFileContent(pathAndFileName):
with open(pathAndFileName, 'r') as theFile:
data = theFile.read()
return data
'''
@TODO check if we have to tweak the value (between 0.0 and 1.0) provided by the Ui24R for ffmpeg's volume filter
@see https://ffmpeg.org/ffmpeg-filters.html#volume
based on some analysis and measurings https://mycurvefit.com came to this formula
y = 1932499 + (0.2518983 − 1932499)/(1 + (x/13.36283)^5.177893)
measurings
---------------------------------------------------------------------
fader value (x) | value of ffmpeg's volume filter to apply (y)
---------------------------------------------------------------------
1 | 3.181
0.9566074950690335 | 2.461
0.897435897435897 | 1.815
0.857988165680473 | 1.535
0.808678500986193 | 1.22
0.7647058823529421 | 1
0.7120315581854044 | 0.8
0.6469428007889547 | 0.597
0.5877712031558187 | 0.441
0.5069033530571994 | 0.254
0.4240631163708088 | 0.158
0.345167652859960 | 0.078
0.22879684418145974 | 0.02
0.12031558185404356 | 0
0.061143984220907464 | 0
---------------------------------------------------------------------
'''
def convertVolumeValue(inputValue):
return 1932499 + (0.2518983-1932499)/(1 + (float(inputValue)/13.36283)**5.177893)
#zeroDB = .7647058823529421
#newValue = float(inputValue) * ( 1/zeroDB)
#return newValue
#return math.log1p(float(inputValue))
def generalCmd(cmdArgsList, description, readStdError = False):
logging.info("starting %s" % description)
logging.debug(' '.join(cmdArgsList))
startTime = time.time()
if readStdError:
process = subprocess.Popen(cmdArgsList, stderr=subprocess.PIPE)
processStdOut = process.stderr.read()
else:
process = subprocess.Popen(cmdArgsList, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
processStdOut = process.stdout.read()
retcode = process.wait()
if retcode != 0:
print ( "ERROR: %s did not complete successfully (error code is %s)" % (description, retcode) )
logging.info("finished %s in %s seconds" % ( description, '{0:.3g}'.format(time.time() - startTime) ) )
return processStdOut.decode('utf-8')
# thanks to: https://stackoverflow.com/questions/38085408/complex-audio-volume-changes-with-ffmpeg
def applyFilter(inputPath, filterParamsPath, outputPath):
cmd = [
'ffmpeg', '-hide_banner', '-v', 'quiet', '-stats', '-y',
'-i', str(inputPath), '-filter_complex_script', str(filterParamsPath),
str(outputPath)
]
generalCmd(cmd, 'apply filter', True)
def mergeMonoToStereo(inputPathLeft, inputPathRight, outputPath):
# TODO: keep bitrate as configured in .uirecsession file or read from input file
cmd = [
'ffmpeg', '-hide_banner', '-v', 'quiet', '-stats', '-y',
'-i', str(inputPathLeft), '-i', str(inputPathRight),
'-filter_complex', '[0:a][1:a]amerge', '-c:a', 'pcm_s16le', '-ar', '44100',
str(outputPath)
]
generalCmd(cmd, 'merge mono to stereo', True)
if __name__ == "__main__":
runStuff()