001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.layer.gpx;
003
004import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
005import static org.openstreetmap.josm.tools.I18n.tr;
006
007import java.awt.event.ActionEvent;
008import java.io.File;
009import java.net.URL;
010import java.util.ArrayList;
011import java.util.Arrays;
012import java.util.Collection;
013import java.util.Comparator;
014
015import javax.swing.AbstractAction;
016import javax.swing.JFileChooser;
017import javax.swing.JOptionPane;
018import javax.swing.filechooser.FileFilter;
019
020import org.openstreetmap.josm.Main;
021import org.openstreetmap.josm.actions.DiskAccessAction;
022import org.openstreetmap.josm.data.gpx.GpxConstants;
023import org.openstreetmap.josm.data.gpx.GpxData;
024import org.openstreetmap.josm.data.gpx.GpxTrack;
025import org.openstreetmap.josm.data.gpx.GpxTrackSegment;
026import org.openstreetmap.josm.data.gpx.WayPoint;
027import org.openstreetmap.josm.gui.HelpAwareOptionPane;
028import org.openstreetmap.josm.gui.layer.GpxLayer;
029import org.openstreetmap.josm.gui.layer.markerlayer.AudioMarker;
030import org.openstreetmap.josm.gui.layer.markerlayer.MarkerLayer;
031import org.openstreetmap.josm.gui.widgets.AbstractFileChooser;
032import org.openstreetmap.josm.tools.AudioUtil;
033import org.openstreetmap.josm.tools.ImageProvider;
034import org.openstreetmap.josm.tools.Utils;
035
036/**
037 * Import audio files into a GPX layer to enable audio playback functions.
038 * @since 5715
039 */
040public class ImportAudioAction extends AbstractAction {
041    private final transient GpxLayer layer;
042
043    private static class Markers {
044        public boolean timedMarkersOmitted;
045        public boolean untimedMarkersOmitted;
046    }
047
048    /**
049     * Constructs a new {@code ImportAudioAction}.
050     * @param layer The associated GPX layer
051     */
052    public ImportAudioAction(final GpxLayer layer) {
053        super(tr("Import Audio"), ImageProvider.get("importaudio"));
054        this.layer = layer;
055        putValue("help", ht("/Action/ImportAudio"));
056    }
057
058    private static void warnCantImportIntoServerLayer(GpxLayer layer) {
059        String msg = tr("<html>The data in the GPX layer ''{0}'' has been downloaded from the server.<br>" +
060                "Because its way points do not include a timestamp we cannot correlate them with audio data.</html>",
061                layer.getName());
062        HelpAwareOptionPane.showOptionDialog(Main.parent, msg, tr("Import not possible"),
063                JOptionPane.WARNING_MESSAGE, ht("/Action/ImportAudio#CantImportIntoGpxLayerFromServer"));
064    }
065
066    @Override
067    public void actionPerformed(ActionEvent e) {
068        if (layer.data.fromServer) {
069            warnCantImportIntoServerLayer(layer);
070            return;
071        }
072        FileFilter filter = new FileFilter() {
073            @Override
074            public boolean accept(File f) {
075                return f.isDirectory() || Utils.hasExtension(f, "wav");
076            }
077
078            @Override
079            public String getDescription() {
080                return tr("Wave Audio files (*.wav)");
081            }
082        };
083        AbstractFileChooser fc = DiskAccessAction.createAndOpenFileChooser(true, true, null, filter,
084                JFileChooser.FILES_ONLY, "markers.lastaudiodirectory");
085        if (fc != null) {
086            File[] sel = fc.getSelectedFiles();
087            // sort files in increasing order of timestamp (this is the end time, but so
088            // long as they don't overlap, that's fine)
089            if (sel.length > 1) {
090                Arrays.sort(sel, Comparator.comparingLong(File::lastModified));
091            }
092            StringBuilder names = new StringBuilder();
093            for (File file : sel) {
094                if (names.length() == 0) {
095                    names.append(" (");
096                } else {
097                    names.append(", ");
098                }
099                names.append(file.getName());
100            }
101            if (names.length() > 0) {
102                names.append(')');
103            }
104            MarkerLayer ml = new MarkerLayer(new GpxData(),
105                    tr("Audio markers from {0}", layer.getName()) + names, layer.getAssociatedFile(), layer);
106            double firstStartTime = sel[0].lastModified() / 1000.0 - AudioUtil.getCalibratedDuration(sel[0]);
107            Markers m = new Markers();
108            for (File file : sel) {
109                importAudio(file, ml, firstStartTime, m);
110            }
111            Main.getLayerManager().addLayer(ml);
112            Main.map.repaint();
113        }
114    }
115
116    /**
117     * Makes a new marker layer derived from this GpxLayer containing at least one audio marker
118     * which the given audio file is associated with. Markers are derived from the following (a)
119     * explict waypoints in the GPX layer, or (b) named trackpoints in the GPX layer, or (d)
120     * timestamp on the wav file (e) (in future) voice recognised markers in the sound recording (f)
121     * a single marker at the beginning of the track
122     * @param wavFile the file to be associated with the markers in the new marker layer
123     * @param ml marker layer
124     * @param firstStartTime first start time in milliseconds, used for (d)
125     * @param markers keeps track of warning messages to avoid repeated warnings
126     */
127    private void importAudio(File wavFile, MarkerLayer ml, double firstStartTime, Markers markers) {
128        URL url = Utils.fileToURL(wavFile);
129        boolean hasTracks = layer.data.tracks != null && !layer.data.tracks.isEmpty();
130        boolean hasWaypoints = layer.data.waypoints != null && !layer.data.waypoints.isEmpty();
131        Collection<WayPoint> waypoints = new ArrayList<>();
132        boolean timedMarkersOmitted = false;
133        boolean untimedMarkersOmitted = false;
134        double snapDistance = Main.pref.getDouble("marker.audiofromuntimedwaypoints.distance", 1.0e-3);
135        // about 25 m
136        WayPoint wayPointFromTimeStamp = null;
137
138        // determine time of first point in track
139        double firstTime = -1.0;
140        if (hasTracks) {
141            for (GpxTrack track : layer.data.tracks) {
142                for (GpxTrackSegment seg : track.getSegments()) {
143                    for (WayPoint w : seg.getWayPoints()) {
144                        firstTime = w.time;
145                        break;
146                    }
147                    if (firstTime >= 0.0) {
148                        break;
149                    }
150                }
151                if (firstTime >= 0.0) {
152                    break;
153                }
154            }
155        }
156        if (firstTime < 0.0) {
157            JOptionPane.showMessageDialog(
158                    Main.parent,
159                    tr("No GPX track available in layer to associate audio with."),
160                    tr("Error"),
161                    JOptionPane.ERROR_MESSAGE
162                    );
163            return;
164        }
165
166        // (a) try explicit timestamped waypoints - unless suppressed
167        if (Main.pref.getBoolean("marker.audiofromexplicitwaypoints", true) && hasWaypoints) {
168            for (WayPoint w : layer.data.waypoints) {
169                if (w.time > firstTime) {
170                    waypoints.add(w);
171                } else if (w.time > 0.0) {
172                    timedMarkersOmitted = true;
173                }
174            }
175        }
176
177        // (b) try explicit waypoints without timestamps - unless suppressed
178        if (Main.pref.getBoolean("marker.audiofromuntimedwaypoints", true) && hasWaypoints) {
179            for (WayPoint w : layer.data.waypoints) {
180                if (waypoints.contains(w)) {
181                    continue;
182                }
183                WayPoint wNear = layer.data.nearestPointOnTrack(w.getEastNorth(), snapDistance);
184                if (wNear != null) {
185                    WayPoint wc = new WayPoint(w.getCoor());
186                    wc.time = wNear.time;
187                    if (w.attr.containsKey(GpxConstants.GPX_NAME)) {
188                        wc.put(GpxConstants.GPX_NAME, w.getString(GpxConstants.GPX_NAME));
189                    }
190                    waypoints.add(wc);
191                } else {
192                    untimedMarkersOmitted = true;
193                }
194            }
195        }
196
197        // (c) use explicitly named track points, again unless suppressed
198        if ((Main.pref.getBoolean("marker.audiofromnamedtrackpoints", false)) && layer.data.tracks != null
199                && !layer.data.tracks.isEmpty()) {
200            for (GpxTrack track : layer.data.tracks) {
201                for (GpxTrackSegment seg : track.getSegments()) {
202                    for (WayPoint w : seg.getWayPoints()) {
203                        if (w.attr.containsKey(GpxConstants.GPX_NAME) || w.attr.containsKey(GpxConstants.GPX_DESC)) {
204                            waypoints.add(w);
205                        }
206                    }
207                }
208            }
209        }
210
211        // (d) use timestamp of file as location on track
212        if ((Main.pref.getBoolean("marker.audiofromwavtimestamps", false)) && hasTracks) {
213            double lastModified = wavFile.lastModified() / 1000.0; // lastModified is in
214            // milliseconds
215            double duration = AudioUtil.getCalibratedDuration(wavFile);
216            double startTime = lastModified - duration;
217            startTime = firstStartTime + (startTime - firstStartTime)
218                    / Main.pref.getDouble("audio.calibration", 1.0 /* default, ratio */);
219            WayPoint w1 = null;
220            WayPoint w2 = null;
221
222            for (GpxTrack track : layer.data.tracks) {
223                for (GpxTrackSegment seg : track.getSegments()) {
224                    for (WayPoint w : seg.getWayPoints()) {
225                        if (startTime < w.time) {
226                            w2 = w;
227                            break;
228                        }
229                        w1 = w;
230                    }
231                    if (w2 != null) {
232                        break;
233                    }
234                }
235            }
236
237            if (w1 == null || w2 == null) {
238                timedMarkersOmitted = true;
239            } else {
240                wayPointFromTimeStamp = new WayPoint(w1.getCoor().interpolate(w2.getCoor(),
241                        (startTime - w1.time) / (w2.time - w1.time)));
242                wayPointFromTimeStamp.time = startTime;
243                String name = wavFile.getName();
244                int dot = name.lastIndexOf('.');
245                if (dot > 0) {
246                    name = name.substring(0, dot);
247                }
248                wayPointFromTimeStamp.put(GpxConstants.GPX_NAME, name);
249                waypoints.add(wayPointFromTimeStamp);
250            }
251        }
252
253        // (e) analyse audio for spoken markers here, in due course
254
255        // (f) simply add a single marker at the start of the track
256        if ((Main.pref.getBoolean("marker.audiofromstart") || waypoints.isEmpty()) && hasTracks) {
257            boolean gotOne = false;
258            for (GpxTrack track : layer.data.tracks) {
259                for (GpxTrackSegment seg : track.getSegments()) {
260                    for (WayPoint w : seg.getWayPoints()) {
261                        WayPoint wStart = new WayPoint(w.getCoor());
262                        wStart.put(GpxConstants.GPX_NAME, "start");
263                        wStart.time = w.time;
264                        waypoints.add(wStart);
265                        gotOne = true;
266                        break;
267                    }
268                    if (gotOne) {
269                        break;
270                    }
271                }
272                if (gotOne) {
273                    break;
274                }
275            }
276        }
277
278        /* we must have got at least one waypoint now */
279
280        ((ArrayList<WayPoint>) waypoints).sort(Comparator.comparingDouble(o -> o.time));
281
282        firstTime = -1.0; /* this time of the first waypoint, not first trackpoint */
283        for (WayPoint w : waypoints) {
284            if (firstTime < 0.0) {
285                firstTime = w.time;
286            }
287            double offset = w.time - firstTime;
288            AudioMarker am = new AudioMarker(w.getCoor(), w, url, ml, w.time, offset);
289            /*
290             * timeFromAudio intended for future use to shift markers of this type on synchronization
291             */
292            if (w == wayPointFromTimeStamp) {
293                am.timeFromAudio = true;
294            }
295            ml.data.add(am);
296        }
297
298        if (timedMarkersOmitted && !markers.timedMarkersOmitted) {
299            JOptionPane
300            .showMessageDialog(
301                    Main.parent,
302                    // CHECKSTYLE.OFF: LineLength
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                    // CHECKSTYLE.ON: LineLength
305            markers.timedMarkersOmitted = timedMarkersOmitted;
306        }
307        if (untimedMarkersOmitted && !markers.untimedMarkersOmitted) {
308            JOptionPane
309            .showMessageDialog(
310                    Main.parent,
311                    tr("Some waypoints which were too far from the track to sensibly estimate their time were omitted."));
312            markers.untimedMarkersOmitted = untimedMarkersOmitted;
313        }
314    }
315}