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.actions.DiskAccessAction;
021import org.openstreetmap.josm.data.gpx.GpxConstants;
022import org.openstreetmap.josm.data.gpx.GpxData;
023import org.openstreetmap.josm.data.gpx.IGpxTrack;
024import org.openstreetmap.josm.data.gpx.IGpxTrackSegment;
025import org.openstreetmap.josm.data.gpx.WayPoint;
026import org.openstreetmap.josm.data.projection.ProjectionRegistry;
027import org.openstreetmap.josm.gui.HelpAwareOptionPane;
028import org.openstreetmap.josm.gui.MainApplication;
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.io.audio.AudioUtil;
034import org.openstreetmap.josm.spi.preferences.Config;
035import org.openstreetmap.josm.tools.ImageProvider;
036import org.openstreetmap.josm.tools.Utils;
037
038/**
039 * Import audio files into a GPX layer to enable audio playback functions.
040 * @since 5715
041 */
042public class ImportAudioAction extends AbstractAction {
043    private final transient GpxLayer layer;
044
045    /**
046     * Audio file filter.
047     * @since 12328
048     */
049    public static final class AudioFileFilter extends FileFilter {
050        @Override
051        public boolean accept(File f) {
052            return f.isDirectory() || Utils.hasExtension(f, "wav", "mp3", "aac", "aif", "aiff");
053        }
054
055        @Override
056        public String getDescription() {
057            return tr("Audio files (*.wav, *.mp3, *.aac, *.aif, *.aiff)");
058        }
059    }
060
061    private static class Markers {
062        public boolean timedMarkersOmitted;
063        public boolean untimedMarkersOmitted;
064    }
065
066    /**
067     * Constructs a new {@code ImportAudioAction}.
068     * @param layer The associated GPX layer
069     */
070    public ImportAudioAction(final GpxLayer layer) {
071        super(tr("Import Audio"));
072        new ImageProvider("importaudio").getResource().attachImageIcon(this, true);
073        this.layer = layer;
074        putValue("help", ht("/Action/ImportAudio"));
075    }
076
077    private static void warnCantImportIntoServerLayer(GpxLayer layer) {
078        String msg = tr("<html>The data in the GPX layer ''{0}'' has been downloaded from the server.<br>" +
079                "Because its way points do not include a timestamp we cannot correlate them with audio data.</html>",
080                Utils.escapeReservedCharactersHTML(layer.getName()));
081        HelpAwareOptionPane.showOptionDialog(MainApplication.getMainFrame(), msg, tr("Import not possible"),
082                JOptionPane.WARNING_MESSAGE, ht("/Action/ImportAudio#CantImportIntoGpxLayerFromServer"));
083    }
084
085    @Override
086    public void actionPerformed(ActionEvent e) {
087        if (layer.data.fromServer) {
088            warnCantImportIntoServerLayer(layer);
089            return;
090        }
091        AbstractFileChooser fc = DiskAccessAction.createAndOpenFileChooser(true, true, null, new AudioFileFilter(),
092                JFileChooser.FILES_ONLY, "markers.lastaudiodirectory");
093        if (fc != null) {
094            File[] sel = fc.getSelectedFiles();
095            // sort files in increasing order of timestamp (this is the end time, but so
096            // long as they don't overlap, that's fine)
097            if (sel.length > 1) {
098                Arrays.sort(sel, Comparator.comparingLong(File::lastModified));
099            }
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}