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