1 | // License: GPL. For details, see LICENSE file.
|
---|
2 | package org.openstreetmap.josm.gui.layer.gpx;
|
---|
3 |
|
---|
4 | import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
|
---|
5 | import static org.openstreetmap.josm.tools.I18n.tr;
|
---|
6 |
|
---|
7 | import java.awt.event.ActionEvent;
|
---|
8 | import java.io.File;
|
---|
9 | import java.net.URL;
|
---|
10 | import java.util.ArrayList;
|
---|
11 | import java.util.Arrays;
|
---|
12 | import java.util.Collection;
|
---|
13 | import java.util.Comparator;
|
---|
14 |
|
---|
15 | import javax.swing.AbstractAction;
|
---|
16 | import javax.swing.JFileChooser;
|
---|
17 | import javax.swing.JOptionPane;
|
---|
18 | import javax.swing.filechooser.FileFilter;
|
---|
19 |
|
---|
20 | import org.openstreetmap.josm.Main;
|
---|
21 | import org.openstreetmap.josm.actions.DiskAccessAction;
|
---|
22 | import org.openstreetmap.josm.data.gpx.GpxConstants;
|
---|
23 | import org.openstreetmap.josm.data.gpx.GpxData;
|
---|
24 | import org.openstreetmap.josm.data.gpx.GpxTrack;
|
---|
25 | import org.openstreetmap.josm.data.gpx.GpxTrackSegment;
|
---|
26 | import org.openstreetmap.josm.data.gpx.WayPoint;
|
---|
27 | import org.openstreetmap.josm.gui.HelpAwareOptionPane;
|
---|
28 | import org.openstreetmap.josm.gui.MainApplication;
|
---|
29 | import org.openstreetmap.josm.gui.layer.GpxLayer;
|
---|
30 | import org.openstreetmap.josm.gui.layer.markerlayer.AudioMarker;
|
---|
31 | import org.openstreetmap.josm.gui.layer.markerlayer.MarkerLayer;
|
---|
32 | import org.openstreetmap.josm.gui.widgets.AbstractFileChooser;
|
---|
33 | import org.openstreetmap.josm.io.audio.AudioUtil;
|
---|
34 | import org.openstreetmap.josm.spi.preferences.Config;
|
---|
35 | import org.openstreetmap.josm.tools.ImageProvider;
|
---|
36 | import org.openstreetmap.josm.tools.Utils;
|
---|
37 |
|
---|
38 | /**
|
---|
39 | * Import audio files into a GPX layer to enable audio playback functions.
|
---|
40 | * @since 5715
|
---|
41 | */
|
---|
42 | public class ImportAudioAction extends AbstractAction {
|
---|
43 | private final transient GpxLayer layer;
|
---|
44 |
|
---|
45 | /**
|
---|
46 | * Audio file filter.
|
---|
47 | * @since 12328
|
---|
48 | */
|
---|
49 | public static final class AudioFileFilter extends FileFilter {
|
---|
50 | @Override
|
---|
51 | public boolean accept(File f) {
|
---|
52 | return f.isDirectory() || Utils.hasExtension(f, "wav", "mp3", "aac", "aif", "aiff");
|
---|
53 | }
|
---|
54 |
|
---|
55 | @Override
|
---|
56 | public String getDescription() {
|
---|
57 | return tr("Audio files (*.wav, *.mp3, *.aac, *.aif, *.aiff)");
|
---|
58 | }
|
---|
59 | }
|
---|
60 |
|
---|
61 | private static class Markers {
|
---|
62 | public boolean timedMarkersOmitted;
|
---|
63 | public boolean untimedMarkersOmitted;
|
---|
64 | }
|
---|
65 |
|
---|
66 | /**
|
---|
67 | * Constructs a new {@code ImportAudioAction}.
|
---|
68 | * @param layer The associated GPX layer
|
---|
69 | */
|
---|
70 | public ImportAudioAction(final GpxLayer layer) {
|
---|
71 | super(tr("Import Audio"), ImageProvider.get("importaudio"));
|
---|
72 | this.layer = layer;
|
---|
73 | putValue("help", ht("/Action/ImportAudio"));
|
---|
74 | }
|
---|
75 |
|
---|
76 | private static void warnCantImportIntoServerLayer(GpxLayer layer) {
|
---|
77 | String msg = tr("<html>The data in the GPX layer ''{0}'' has been downloaded from the server.<br>" +
|
---|
78 | "Because its way points do not include a timestamp we cannot correlate them with audio data.</html>",
|
---|
79 | Utils.escapeReservedCharactersHTML(layer.getName()));
|
---|
80 | HelpAwareOptionPane.showOptionDialog(Main.parent, msg, tr("Import not possible"),
|
---|
81 | JOptionPane.WARNING_MESSAGE, ht("/Action/ImportAudio#CantImportIntoGpxLayerFromServer"));
|
---|
82 | }
|
---|
83 |
|
---|
84 | @Override
|
---|
85 | public void actionPerformed(ActionEvent e) {
|
---|
86 | if (layer.data.fromServer) {
|
---|
87 | warnCantImportIntoServerLayer(layer);
|
---|
88 | return;
|
---|
89 | }
|
---|
90 | AbstractFileChooser fc = DiskAccessAction.createAndOpenFileChooser(true, true, null, new AudioFileFilter(),
|
---|
91 | JFileChooser.FILES_ONLY, "markers.lastaudiodirectory");
|
---|
92 | if (fc != null) {
|
---|
93 | File[] sel = fc.getSelectedFiles();
|
---|
94 | // sort files in increasing order of timestamp (this is the end time, but so
|
---|
95 | // long as they don't overlap, that's fine)
|
---|
96 | if (sel.length > 1) {
|
---|
97 | Arrays.sort(sel, Comparator.comparingLong(File::lastModified));
|
---|
98 | }
|
---|
99 | StringBuilder names = new StringBuilder();
|
---|
100 | for (File file : sel) {
|
---|
101 | if (names.length() == 0) {
|
---|
102 | names.append(" (");
|
---|
103 | } else {
|
---|
104 | names.append(", ");
|
---|
105 | }
|
---|
106 | names.append(file.getName());
|
---|
107 | }
|
---|
108 | if (names.length() > 0) {
|
---|
109 | names.append(')');
|
---|
110 | }
|
---|
111 | MarkerLayer ml = new MarkerLayer(new GpxData(),
|
---|
112 | tr("Audio markers from {0}", layer.getName()) + names, layer.getAssociatedFile(), layer);
|
---|
113 | double firstStartTime = sel[0].lastModified() / 1000.0 - AudioUtil.getCalibratedDuration(sel[0]);
|
---|
114 | Markers m = new Markers();
|
---|
115 | for (File file : sel) {
|
---|
116 | importAudio(file, ml, firstStartTime, m);
|
---|
117 | }
|
---|
118 | MainApplication.getLayerManager().addLayer(ml);
|
---|
119 | MainApplication.getMap().repaint();
|
---|
120 | }
|
---|
121 | }
|
---|
122 |
|
---|
123 | /**
|
---|
124 | * Makes a new marker layer derived from this GpxLayer containing at least one audio marker
|
---|
125 | * which the given audio file is associated with. Markers are derived from the following (a)
|
---|
126 | * explict waypoints in the GPX layer, or (b) named trackpoints in the GPX layer, or (d)
|
---|
127 | * timestamp on the audio file (e) (in future) voice recognised markers in the sound recording (f)
|
---|
128 | * a single marker at the beginning of the track
|
---|
129 | * @param audioFile the file to be associated with the markers in the new marker layer
|
---|
130 | * @param ml marker layer
|
---|
131 | * @param firstStartTime first start time in milliseconds, used for (d)
|
---|
132 | * @param markers keeps track of warning messages to avoid repeated warnings
|
---|
133 | */
|
---|
134 | private void importAudio(File audioFile, MarkerLayer ml, double firstStartTime, Markers markers) {
|
---|
135 | URL url = Utils.fileToURL(audioFile);
|
---|
136 | boolean hasTracks = layer.data.tracks != null && !layer.data.tracks.isEmpty();
|
---|
137 | boolean hasWaypoints = layer.data.waypoints != null && !layer.data.waypoints.isEmpty();
|
---|
138 | Collection<WayPoint> waypoints = new ArrayList<>();
|
---|
139 | boolean timedMarkersOmitted = false;
|
---|
140 | boolean untimedMarkersOmitted = false;
|
---|
141 | double snapDistance = Config.getPref().getDouble("marker.audiofromuntimedwaypoints.distance", 1.0e-3);
|
---|
142 | // about 25 m
|
---|
143 | WayPoint wayPointFromTimeStamp = null;
|
---|
144 |
|
---|
145 | // determine time of first point in track
|
---|
146 | double firstTime = -1.0;
|
---|
147 | if (hasTracks) {
|
---|
148 | for (GpxTrack track : layer.data.tracks) {
|
---|
149 | for (GpxTrackSegment seg : track.getSegments()) {
|
---|
150 | for (WayPoint w : seg.getWayPoints()) {
|
---|
151 | firstTime = w.time;
|
---|
152 | break;
|
---|
153 | }
|
---|
154 | if (firstTime >= 0.0) {
|
---|
155 | break;
|
---|
156 | }
|
---|
157 | }
|
---|
158 | if (firstTime >= 0.0) {
|
---|
159 | break;
|
---|
160 | }
|
---|
161 | }
|
---|
162 | }
|
---|
163 | if (firstTime < 0.0) {
|
---|
164 | JOptionPane.showMessageDialog(
|
---|
165 | Main.parent,
|
---|
166 | tr("No GPX track available in layer to associate audio with."),
|
---|
167 | tr("Error"),
|
---|
168 | JOptionPane.ERROR_MESSAGE
|
---|
169 | );
|
---|
170 | return;
|
---|
171 | }
|
---|
172 |
|
---|
173 | // (a) try explicit timestamped waypoints - unless suppressed
|
---|
174 | if (hasWaypoints && Config.getPref().getBoolean("marker.audiofromexplicitwaypoints", true)) {
|
---|
175 | for (WayPoint w : layer.data.waypoints) {
|
---|
176 | if (w.time > firstTime) {
|
---|
177 | waypoints.add(w);
|
---|
178 | } else if (w.time > 0.0) {
|
---|
179 | timedMarkersOmitted = true;
|
---|
180 | }
|
---|
181 | }
|
---|
182 | }
|
---|
183 |
|
---|
184 | // (b) try explicit waypoints without timestamps - unless suppressed
|
---|
185 | if (hasWaypoints && Config.getPref().getBoolean("marker.audiofromuntimedwaypoints", true)) {
|
---|
186 | for (WayPoint w : layer.data.waypoints) {
|
---|
187 | if (waypoints.contains(w)) {
|
---|
188 | continue;
|
---|
189 | }
|
---|
190 | WayPoint wNear = layer.data.nearestPointOnTrack(w.getEastNorth(Main.getProjection()), snapDistance);
|
---|
191 | if (wNear != null) {
|
---|
192 | WayPoint wc = new WayPoint(w.getCoor());
|
---|
193 | wc.time = wNear.time;
|
---|
194 | if (w.attr.containsKey(GpxConstants.GPX_NAME)) {
|
---|
195 | wc.put(GpxConstants.GPX_NAME, w.getString(GpxConstants.GPX_NAME));
|
---|
196 | }
|
---|
197 | waypoints.add(wc);
|
---|
198 | } else {
|
---|
199 | untimedMarkersOmitted = true;
|
---|
200 | }
|
---|
201 | }
|
---|
202 | }
|
---|
203 |
|
---|
204 | // (c) use explicitly named track points, again unless suppressed
|
---|
205 | if (layer.data.tracks != null && Config.getPref().getBoolean("marker.audiofromnamedtrackpoints", false)
|
---|
206 | && !layer.data.tracks.isEmpty()) {
|
---|
207 | for (GpxTrack track : layer.data.tracks) {
|
---|
208 | for (GpxTrackSegment seg : track.getSegments()) {
|
---|
209 | for (WayPoint w : seg.getWayPoints()) {
|
---|
210 | if (w.attr.containsKey(GpxConstants.GPX_NAME) || w.attr.containsKey(GpxConstants.GPX_DESC)) {
|
---|
211 | waypoints.add(w);
|
---|
212 | }
|
---|
213 | }
|
---|
214 | }
|
---|
215 | }
|
---|
216 | }
|
---|
217 |
|
---|
218 | // (d) use timestamp of file as location on track
|
---|
219 | if (hasTracks && Config.getPref().getBoolean("marker.audiofromwavtimestamps", false)) {
|
---|
220 | double lastModified = audioFile.lastModified() / 1000.0; // lastModified is in milliseconds
|
---|
221 | double duration = AudioUtil.getCalibratedDuration(audioFile);
|
---|
222 | double startTime = lastModified - duration;
|
---|
223 | startTime = firstStartTime + (startTime - firstStartTime)
|
---|
224 | / Config.getPref().getDouble("audio.calibration", 1.0 /* default, ratio */);
|
---|
225 | WayPoint w1 = null;
|
---|
226 | WayPoint w2 = null;
|
---|
227 |
|
---|
228 | for (GpxTrack track : layer.data.tracks) {
|
---|
229 | for (GpxTrackSegment seg : track.getSegments()) {
|
---|
230 | for (WayPoint w : seg.getWayPoints()) {
|
---|
231 | if (startTime < w.time) {
|
---|
232 | w2 = w;
|
---|
233 | break;
|
---|
234 | }
|
---|
235 | w1 = w;
|
---|
236 | }
|
---|
237 | if (w2 != null) {
|
---|
238 | break;
|
---|
239 | }
|
---|
240 | }
|
---|
241 | }
|
---|
242 |
|
---|
243 | if (w1 == null || w2 == null) {
|
---|
244 | timedMarkersOmitted = true;
|
---|
245 | } else {
|
---|
246 | wayPointFromTimeStamp = new WayPoint(w1.getCoor().interpolate(w2.getCoor(),
|
---|
247 | (startTime - w1.time) / (w2.time - w1.time)));
|
---|
248 | wayPointFromTimeStamp.time = startTime;
|
---|
249 | String name = audioFile.getName();
|
---|
250 | int dot = name.lastIndexOf('.');
|
---|
251 | if (dot > 0) {
|
---|
252 | name = name.substring(0, dot);
|
---|
253 | }
|
---|
254 | wayPointFromTimeStamp.put(GpxConstants.GPX_NAME, name);
|
---|
255 | waypoints.add(wayPointFromTimeStamp);
|
---|
256 | }
|
---|
257 | }
|
---|
258 |
|
---|
259 | // (e) analyse audio for spoken markers here, in due course
|
---|
260 |
|
---|
261 | // (f) simply add a single marker at the start of the track
|
---|
262 | if ((Config.getPref().getBoolean("marker.audiofromstart") || waypoints.isEmpty()) && hasTracks) {
|
---|
263 | boolean gotOne = false;
|
---|
264 | for (GpxTrack track : layer.data.tracks) {
|
---|
265 | for (GpxTrackSegment seg : track.getSegments()) {
|
---|
266 | for (WayPoint w : seg.getWayPoints()) {
|
---|
267 | WayPoint wStart = new WayPoint(w.getCoor());
|
---|
268 | wStart.put(GpxConstants.GPX_NAME, "start");
|
---|
269 | wStart.time = w.time;
|
---|
270 | waypoints.add(wStart);
|
---|
271 | gotOne = true;
|
---|
272 | break;
|
---|
273 | }
|
---|
274 | if (gotOne) {
|
---|
275 | break;
|
---|
276 | }
|
---|
277 | }
|
---|
278 | if (gotOne) {
|
---|
279 | break;
|
---|
280 | }
|
---|
281 | }
|
---|
282 | }
|
---|
283 |
|
---|
284 | // we must have got at least one waypoint now
|
---|
285 | ((ArrayList<WayPoint>) waypoints).sort(Comparator.comparingDouble(o -> o.time));
|
---|
286 |
|
---|
287 | firstTime = -1.0; // this time of the first waypoint, not first trackpoint
|
---|
288 | for (WayPoint w : waypoints) {
|
---|
289 | if (firstTime < 0.0) {
|
---|
290 | firstTime = w.time;
|
---|
291 | }
|
---|
292 | double offset = w.time - firstTime;
|
---|
293 | AudioMarker am = new AudioMarker(w.getCoor(), w, url, ml, w.time, offset);
|
---|
294 | // timeFromAudio intended for future use to shift markers of this type on synchronization
|
---|
295 | if (w == wayPointFromTimeStamp) {
|
---|
296 | am.timeFromAudio = true;
|
---|
297 | }
|
---|
298 | ml.data.add(am);
|
---|
299 | }
|
---|
300 |
|
---|
301 | if (timedMarkersOmitted && !markers.timedMarkersOmitted) {
|
---|
302 | JOptionPane.showMessageDialog(Main.parent,
|
---|
303 | tr("Some waypoints with timestamps from before the start of the track or after the end were omitted or moved to the start."));
|
---|
304 | markers.timedMarkersOmitted = timedMarkersOmitted;
|
---|
305 | }
|
---|
306 | if (untimedMarkersOmitted && !markers.untimedMarkersOmitted) {
|
---|
307 | JOptionPane.showMessageDialog(Main.parent,
|
---|
308 | tr("Some waypoints which were too far from the track to sensibly estimate their time were omitted."));
|
---|
309 | markers.untimedMarkersOmitted = untimedMarkersOmitted;
|
---|
310 | }
|
---|
311 | }
|
---|
312 | }
|
---|