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}