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}