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