-
Notifications
You must be signed in to change notification settings - Fork 514
/
waveform.py
188 lines (149 loc) · 7.35 KB
/
waveform.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
"""
@file
@brief This file has code to generate audio waveform data structures
@author Jonathan Thomas <jonathan@openshot.org>
@section LICENSE
Copyright (c) 2008-2018 OpenShot Studios, LLC
(http://www.openshotstudios.com). This file is part of
OpenShot Video Editor (http://www.openshot.org), an open-source project
dedicated to delivering high quality video editing and animation solutions
to the world.
OpenShot Video Editor is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
OpenShot Video Editor is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with OpenShot Library. If not, see <http://www.gnu.org/licenses/>.
"""
import threading
from classes.app import get_app
from classes.logger import log
from classes.query import File, Clip
from PyQt5.QtGui import QCursor
from PyQt5.QtCore import Qt
import openshot
import uuid
# Get settings
s = get_app().get_settings()
# resolution of audio waveform
SAMPLES_PER_SECOND = 20
def get_audio_data(files: dict, transaction_id=None):
"""Get a Clip object form libopenshot, and grab audio data
For for the given files and clips, start threads to gather audio data.
arg1: a dict of clip_ids grouped by their file_id
"""
for file_id in files:
clip_list = files[file_id]
log.info("Clip loaded, start thread")
t = threading.Thread(target=get_waveform_thread, args=[file_id, clip_list, transaction_id], daemon=True)
t.start()
def get_waveform_thread(file_id, clip_list, transaction_id):
"""
For the given file ID and clip IDs, update audio data.
arg1: file id to get the audio data of.
arg2: list of clips to update when the audio data is ready.
arg3: tid: transaction id to group waveform saves together
"""
def getAudioData(file, channel=-1, tid=None):
"""
Update the file query object with audio data (if found).
"""
# Ensure that UI attribute exists
file_data = file.data
file_audio_data = file_data.get("ui", {}).get("audio_data", [])
if file_audio_data and channel == -1:
log.info("Audio Data already retrieved (or being retrieved).")
return
# Open file and access audio data (if audio data is found, otherwise return)
temp_clip = openshot.Clip(file_data["path"])
if temp_clip.Reader().info.has_audio == False:
log.info(f"file: {file_data['path']} has no audio_data. Skipping")
return
# Show waiting cursor
get_app().setOverrideCursor(QCursor(Qt.WaitCursor))
# Extract audio waveform data (for all channels)
# Use max RMS (root mean squared) value for each sample
# NOTE: we also have the average RMS value calculated, although we do
# not use it yet
waveformer = openshot.AudioWaveformer(temp_clip.Reader())
file_audio_data = waveformer.ExtractSamples(channel, SAMPLES_PER_SECOND, True)
samples_vectors = file_audio_data.vectors()
max_samples_vector = samples_vectors[0] # max sample value dataset
rms_samples_vector = samples_vectors[1] # average RMS sample value dataset
# Clear data
file_audio_data.clear()
# Update file with audio data (only if all channels requested)
if channel == -1:
get_app().window.timeline.fileAudioDataReady.emit(file.id, {"ui": {"audio_data": max_samples_vector}}, tid)
# Restore cursor
get_app().restoreOverrideCursor()
# Return audio sample dataset
return max_samples_vector
# Get file query object
file = File.get(id=file_id)
# Only generate audio for readers that actually contain audio
if not file.data.get("has_audio", False):
log.info("File does not have audio. Skipping")
return
# Transaction id to group all deletes together
if transaction_id:
tid = transaction_id
else:
tid = str(uuid.uuid4())
# If the file doesn't have audio data, generate it.
# A pending audio_data process will have audio_data == [-999]
file_audio_data = file.data.get("ui", {}).get("audio_data", [])
if not file_audio_data:
log.debug("Generating audio data for file %s" % file.id)
# Save empty 'audio_data' property before we get audio samples
get_app().window.timeline.fileAudioDataReady.emit(file.id, {"ui": {"audio_data": None}}, tid)
# Generate audio data for a specific file
file_audio_data = getAudioData(file, tid=tid)
if not file_audio_data:
log.info("No audio data found. Aborting")
return
log.debug("Audio data found for file: %s" % file.data.get("path"))
# Loop through each selected clip (which uses this file)
for clip_id in clip_list:
clip = Clip.get(id=clip_id)
if not clip:
# Ignore null clip
log.debug(f"No clip found for ID: {clip_id}. Skipping waveform generation.")
continue
# Check for channel mapping and filters
channel_filter = int(clip.data.get("channel_filter", {}).get("Points", [])[0].get("co", {}).get("Y", -1))
if channel_filter != -1:
# Some kind of filtering is happening, so we need to re-generate waveform data for this clip
file_audio_data = getAudioData(file, channel_filter, tid=tid)
# Get File's audio data (since it has changed)
if not file_audio_data:
log.info("File has no audio, so we cannot find any waveform audio data")
continue
# Save empty 'audio_data' property before we get audio samples
get_app().window.timeline.clipAudioDataReady.emit(clip.id, {"ui": {"audio_data": None}}, tid)
# Loop through samples from the file, applying this clip's volume curve
clip_audio_data = []
clip_instance = get_app().window.timeline_sync.timeline.GetClip(clip.id)
num_frames = clip_instance.info.video_length
# Determine best guess # of samples (based on duration)
# We don't want to use the len(file_audio_data) due to padding at EOF
# from libopenshot
sample_count = round(clip_instance.info.duration * SAMPLES_PER_SECOND)
# Determine sample ratio to FPS
sample_ratio = float(sample_count / num_frames)
# Loop through file samples and adjust time/volume values
# Copy adjusted samples into clip data
for sample_index in range(sample_count):
frame_num = round(sample_index / sample_ratio) + 1
volume = clip_instance.volume.GetValue(frame_num)
if clip_instance.time.GetCount() > 1:
# Override sample # using time curve (if set)
# Don't exceed array size
sample_index = min(round(clip_instance.time.GetValue(frame_num) * sample_ratio), sample_count - 1)
clip_audio_data.append(file_audio_data[sample_index] * volume)
# Save this data to the clip object
get_app().window.timeline.clipAudioDataReady.emit(clip.id, {"ui": {"audio_data": clip_audio_data}}, tid)