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