1 | // License: GPL. For details, see LICENSE file.
|
---|
2 | package org.openstreetmap.josm.gui.layer.gpx;
|
---|
3 |
|
---|
4 | import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
|
---|
5 | import static org.openstreetmap.josm.tools.I18n.tr;
|
---|
6 |
|
---|
7 | import java.awt.event.ActionEvent;
|
---|
8 | import java.io.File;
|
---|
9 | import java.net.URL;
|
---|
10 | import java.util.ArrayList;
|
---|
11 | import java.util.Arrays;
|
---|
12 | import java.util.Comparator;
|
---|
13 | import java.util.List;
|
---|
14 | import java.util.stream.Collectors;
|
---|
15 |
|
---|
16 | import javax.swing.AbstractAction;
|
---|
17 | import javax.swing.JFileChooser;
|
---|
18 | import javax.swing.JOptionPane;
|
---|
19 | import javax.swing.filechooser.FileFilter;
|
---|
20 |
|
---|
21 | import org.openstreetmap.josm.actions.DiskAccessAction;
|
---|
22 | import org.openstreetmap.josm.data.gpx.GpxConstants;
|
---|
23 | import org.openstreetmap.josm.data.gpx.GpxData;
|
---|
24 | import org.openstreetmap.josm.data.gpx.IGpxTrack;
|
---|
25 | import org.openstreetmap.josm.data.gpx.IGpxTrackSegment;
|
---|
26 | import org.openstreetmap.josm.data.gpx.WayPoint;
|
---|
27 | import org.openstreetmap.josm.data.projection.ProjectionRegistry;
|
---|
28 | import org.openstreetmap.josm.gui.HelpAwareOptionPane;
|
---|
29 | import org.openstreetmap.josm.gui.MainApplication;
|
---|
30 | import org.openstreetmap.josm.gui.layer.GpxLayer;
|
---|
31 | import org.openstreetmap.josm.gui.layer.markerlayer.AudioMarker;
|
---|
32 | import org.openstreetmap.josm.gui.layer.markerlayer.MarkerLayer;
|
---|
33 | import org.openstreetmap.josm.gui.widgets.AbstractFileChooser;
|
---|
34 | import org.openstreetmap.josm.io.audio.AudioUtil;
|
---|
35 | import org.openstreetmap.josm.spi.preferences.Config;
|
---|
36 | import org.openstreetmap.josm.tools.ImageProvider;
|
---|
37 | import org.openstreetmap.josm.tools.Utils;
|
---|
38 |
|
---|
39 | /**
|
---|
40 | * Import audio files into a GPX layer to enable audio playback functions.
|
---|
41 | * @since 5715
|
---|
42 | */
|
---|
43 | public class ImportAudioAction extends AbstractAction {
|
---|
44 | private final transient GpxLayer layer;
|
---|
45 |
|
---|
46 | /**
|
---|
47 | * Audio file filter.
|
---|
48 | * @since 12328
|
---|
49 | */
|
---|
50 | public static final class AudioFileFilter extends FileFilter {
|
---|
51 | @Override
|
---|
52 | public boolean accept(File f) {
|
---|
53 | return f.isDirectory() || Utils.hasExtension(f, "wav", "mp3", "aac", "aif", "aiff");
|
---|
54 | }
|
---|
55 |
|
---|
56 | @Override
|
---|
57 | public String getDescription() {
|
---|
58 | return tr("Audio files (*.wav, *.mp3, *.aac, *.aif, *.aiff)");
|
---|
59 | }
|
---|
60 | }
|
---|
61 |
|
---|
62 | private static final class Markers {
|
---|
63 | public boolean timedMarkersOmitted;
|
---|
64 | public boolean untimedMarkersOmitted;
|
---|
65 | }
|
---|
66 |
|
---|
67 | /**
|
---|
68 | * Constructs a new {@code ImportAudioAction}.
|
---|
69 | * @param layer The associated GPX layer
|
---|
70 | */
|
---|
71 | public ImportAudioAction(final GpxLayer layer) {
|
---|
72 | super(tr("Import Audio"));
|
---|
73 | new ImageProvider("importaudio").getResource().attachImageIcon(this, true);
|
---|
74 | this.layer = layer;
|
---|
75 | putValue("help", ht("/Action/ImportAudio"));
|
---|
76 | }
|
---|
77 |
|
---|
78 | private static void warnCantImportIntoServerLayer(GpxLayer layer) {
|
---|
79 | String msg = tr("<html>The data in the GPX layer ''{0}'' has been downloaded from the server.<br>" +
|
---|
80 | "Because its way points do not include a timestamp we cannot correlate them with audio data.</html>",
|
---|
81 | Utils.escapeReservedCharactersHTML(layer.getName()));
|
---|
82 | HelpAwareOptionPane.showOptionDialog(MainApplication.getMainFrame(), msg, tr("Import not possible"),
|
---|
83 | JOptionPane.WARNING_MESSAGE, ht("/Action/ImportAudio#CantImportIntoGpxLayerFromServer"));
|
---|
84 | }
|
---|
85 |
|
---|
86 | @Override
|
---|
87 | public void actionPerformed(ActionEvent e) {
|
---|
88 | if (layer.data.fromServer) {
|
---|
89 | warnCantImportIntoServerLayer(layer);
|
---|
90 | return;
|
---|
91 | }
|
---|
92 | AbstractFileChooser fc = DiskAccessAction.createAndOpenFileChooser(true, true, null, new AudioFileFilter(),
|
---|
93 | JFileChooser.FILES_ONLY, "markers.lastaudiodirectory");
|
---|
94 | if (fc != null) {
|
---|
95 | File[] sel = fc.getSelectedFiles();
|
---|
96 | String names = Arrays.stream(sel)
|
---|
97 | // sort files in increasing order of timestamp (this is the end time, but so long as they don't overlap, that's fine)
|
---|
98 | .sorted(Comparator.comparingLong(File::lastModified))
|
---|
99 | .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 | List<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 | 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 | }
|
---|