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