Skip to content

Commit

Permalink
Split non-audio parts of announcer.ts into feature.ts
Browse files Browse the repository at this point in the history
  • Loading branch information
steinbro committed Nov 25, 2024
1 parent d8ba334 commit f928de0
Show file tree
Hide file tree
Showing 2 changed files with 143 additions and 146 deletions.
171 changes: 25 additions & 146 deletions src/composables/announcer.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,14 @@
// Copyright (c) Daniel W. Steinbrook.
// with many thanks to ChatGPT

import { Feature, LineString, Point } from 'geojson';
import { centroid, nearestPointOnLine } from '@turf/turf';
import { cache, SoundscapeFeature } from "../state/cache";
import { Feature, Point } from 'geojson';
import { audioQueue } from '../state/audio';
import { enumerateTilesAround } from "../composables/tile";
import { watch } from 'vue';
import { myLocation, myTurfPoint, distanceTo } from '../state/location';
import { myLocation } from '../state/location';
import { SpeakableFeature, nearbyFeatures, nearbyRoads } from "./feature";

const sense_mobility_wav = new URL("/assets/sounds/sense_mobility.wav", import.meta.url).href;
const sense_poi_wav = new URL("/assets/sounds/sense_poi.wav", import.meta.url).href;

type AnnounceableFeature = SoundscapeFeature & {
point: Feature<Point>;
distance: number;
soundEffectUrl: string;
getAudioLabel: () => Promise<string | undefined>;
announce: (options: { includeDistance: boolean }) => Promise<boolean>;
}

interface Announcer {
nearbyFeatures: (latitude: number, longitude: number, radiusMeters: number) => Promise<AnnounceableFeature[]>;
nearbyRoads: (latitude: number, longitude: number, radiusMeters: number) => Promise<AnnounceableFeature[]>;
calloutAllFeatures: (latitude: number, longitude: number) => Promise<boolean>;
calloutAllFeaturesOrSayNoneFound: (latitude: number, longitude: number) => void;
calloutNewFeatures: (latitude:number, longitude:number) => void;
Expand Down Expand Up @@ -78,141 +64,34 @@ function useAnnouncer() {
});
}

// Get names of intersecting roads by looking up each road individually
function getRoadNames(intersectionFeature: SoundscapeFeature): Promise<Set<string>> {
return Promise.all(
intersectionFeature.osm_ids.map(id => cache.getFeatureByOsmId(id))
).then(
(roads) =>
new Set(
roads
.filter((r) => r && r.properties && r.properties.name !== undefined)
.map((r) => r!.properties!.name)
)
);
}

// Annotate GeoJSON feature with attributes and methods used for spatial audio callouts
function announceable(feature: SoundscapeFeature): AnnounceableFeature {
// Method for computing distance depends on geometry, e.g. finding nearest
// point on a line, or to the centroid of a polygon.
let point = centroid(feature.geometry);
if (feature.geometry.type === "LineString") { // e.g. roads
point = nearestPointOnLine(
feature as Feature<LineString>,
myTurfPoint.value,
{ units: "meters" }
);
}

let extendedFeature: AnnounceableFeature = {
...feature,
point: point,
distance: distanceTo.value(point, { units: "meters" }),

//TODO for now, all callouts are POIs
soundEffectUrl: sense_poi_wav,

getAudioLabel: async function (): Promise<string | undefined> {
// Determine audio label from feature type
switch (feature.feature_type) {
case "highway":
switch (feature.feature_value) {
case "gd_intersection":
// Speak intersections involving 2 or more named roads
return getRoadNames(feature).then((roadNames) => {
if (roadNames.size > 1) {
return "Intersection: " + [...roadNames].join(", ");
}
});
break;
case "bus_stop":
//TODO
break;
//TODO case ...
}
break;
default:
// Speak anything else with a name
if (feature.properties) {
return feature.properties.name;
}
}
},

// Speaks a feature if it has a non-empty audio label (returns true if so)
announce: (options: { includeDistance: boolean }): Promise<boolean> => {
return extendedFeature.getAudioLabel().then((label) => {
if (label) {
spokenRecently.add(feature.osm_ids);
playSoundAndSpeech(
extendedFeature.soundEffectUrl,
label,
extendedFeature.point,
options.includeDistance
);
return true;
} else {
return false;
}
});
},
}

return extendedFeature;
// Speaks a feature if it has a non-empty audio label (returns true if so)
function announce(feature: SpeakableFeature, options: { includeDistance: boolean }): Promise<boolean> {
return feature.getAudioLabel().then((label) => {
if (label) {
spokenRecently.add(feature.osm_ids);
playSoundAndSpeech(
feature.soundEffectUrl,
label,
feature.speechOrigin,
options.includeDistance
);
return true;
} else {
return false;
}
});
}

const announcer: Announcer = {
nearbyFeatures: (latitude: number, longitude: number, radiusMeters: number): Promise<AnnounceableFeature[]> => {
return Promise.all(
// Get all features from nearby tiles
enumerateTilesAround(latitude, longitude, radiusMeters).map((t) => {
t.load();
return t.getFeatures();
})
).then((tileFeatures) => {
// Flatten list of features across all nearby tiles
return (
tileFeatures
.reduce((acc, cur) => acc.concat(cur), [])
// Annotate each feature with its center and distance to our location
.map(feature => announceable(feature))
// Limit to features within the specified radius
.filter((f) => f.distance < radiusMeters)
// Sort by closest features first
.sort((a, b) => a.distance - b.distance)
);
});
},

// Filter nearby features to just named roads.
nearbyRoads: (latitude: number, longitude: number, radiusMeters: number): Promise<AnnounceableFeature[]> => {
return announcer
.nearbyFeatures(latitude, longitude, radiusMeters)
.then((features) =>
features.filter(
(f) =>
f.feature_type == "highway" &&
f.geometry.type == "LineString" &&
["primary", "residential", "tertiary"].includes(
f.feature_value
) &&
f.properties &&
f.properties.name
)
);
},

// Announce all speakable nearby features
// Returns true if anything was queued for speaking
calloutAllFeatures: (latitude: number, longitude: number): Promise<boolean> => {
// Use 2x wider radius than standard location updates
const radiusMeters = 2 * myLocation.radiusMeters;
return announcer
.nearbyFeatures(latitude, longitude, radiusMeters)
return nearbyFeatures(latitude, longitude, radiusMeters)
.then((fs) => {
return Promise.all(
fs.map((f) => f.announce({ includeDistance: true }))
fs.map((f) => announce(f, { includeDistance: true }))
).then((willAnnounce) => willAnnounce.some((x) => x));
});
},
Expand All @@ -233,22 +112,22 @@ function useAnnouncer() {
// Announce only features not already called out (useful for continuous tracking)
calloutNewFeatures: (latitude: number, longitude: number) => {
const radiusMeters = myLocation.radiusMeters;
announcer.nearbyFeatures(latitude, longitude, radiusMeters).then((fs) => {
nearbyFeatures(latitude, longitude, radiusMeters).then((fs) => {
// Omit features already announced
fs.filter((f) => !spokenRecently.has(f.osm_ids)).forEach((f) =>
f.announce({ includeDistance: false })
announce(f, { includeDistance: false })
);
});
},

calloutNearestRoad: (latitude: number, longitude: number) => {
const radiusMeters = myLocation.radiusMeters;
announcer.nearbyRoads(latitude, longitude, radiusMeters).then((roads) => {
nearbyRoads(latitude, longitude, radiusMeters).then((roads) => {
if (roads.length > 0 && roads[0].properties) {
playSoundAndSpeech(
sense_mobility_wav,
`Nearest road: ${roads[0].properties.name}`,
roads[0].point,
roads[0].speechOrigin,
true
);
}
Expand Down
118 changes: 118 additions & 0 deletions src/composables/feature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Copyright (c) Daniel W. Steinbrook.

import { Feature, LineString, Point } from 'geojson';
import { centroid, nearestPointOnLine } from '@turf/turf';
import { cache, SoundscapeFeature } from '../state/cache';
import { enumerateTilesAround } from "../composables/tile";
import { myTurfPoint, distanceTo } from '../state/location';

const sense_poi_wav = new URL("/assets/sounds/sense_poi.wav", import.meta.url).href;

// Annotated GeoJSON feature with attributes and methods used for spatial audio callouts
export type SpeakableFeature = SoundscapeFeature & {
speechOrigin: Feature<Point>;
distance: number;
soundEffectUrl: string;
getAudioLabel: () => Promise<string | undefined>;
}

function speakable(feature: SoundscapeFeature): SpeakableFeature {
// Method for computing distance depends on geometry, e.g. finding nearest
// point on a line, or to the centroid of a polygon.
let point = centroid(feature.geometry);
if (feature.geometry.type === "LineString") { // e.g. roads
point = nearestPointOnLine(
feature as Feature<LineString>,
myTurfPoint.value,
{ units: "meters" }
);
}

let extendedFeature: SpeakableFeature = {
...feature,
speechOrigin: point,
distance: distanceTo.value(point, { units: "meters" }),

//TODO for now, all callouts are POIs
soundEffectUrl: sense_poi_wav,

getAudioLabel: async function (): Promise<string | undefined> {
// Determine audio label from feature type
switch (feature.feature_type) {
case "highway":
switch (feature.feature_value) {
case "gd_intersection":
// Speak intersections involving 2 or more named roads
return getRoadNames(feature).then((roadNames) => {
if (roadNames.size > 1) {
return "Intersection: " + [...roadNames].join(", ");
}
});
break;
case "bus_stop":
//TODO
break;
//TODO case ...
}
break;
default:
// Speak anything else with a name
if (feature.properties) {
return feature.properties.name;
}
}
},
}

return extendedFeature;
}

export function nearbyFeatures(latitude: number, longitude: number, radiusMeters: number): Promise<SpeakableFeature[]> {
return Promise.all(
// Get all features from nearby tiles
enumerateTilesAround(latitude, longitude, radiusMeters).map((t) => {
t.load();
return t.getFeatures();
})
).then((tileFeatures) => {
// Flatten list of features across all nearby tiles
return (
tileFeatures
.reduce((acc, cur) => acc.concat(cur), [])
// Annotate each feature with its center and distance to our location
.map(feature => speakable(feature))
// Limit to features within the specified radius
.filter((f) => f.distance < radiusMeters)
// Sort by closest features first
.sort((a, b) => a.distance - b.distance)
);
});
};

// Filter nearby features to just named roads.
export function nearbyRoads(latitude: number, longitude: number, radiusMeters: number): Promise<SpeakableFeature[]> {
return nearbyFeatures(latitude, longitude, radiusMeters)
.then((features) =>
features.filter(
(f) =>
f.feature_type == "highway" &&
f.geometry.type == "LineString" &&
["primary", "residential", "tertiary"].includes(
f.feature_value
) &&
f.properties &&
f.properties.name
)
);
};

// Get names of intersecting roads by looking up each road individually
function getRoadNames(intersectionFeature: SoundscapeFeature): Promise<Set<string>> {
return Promise.all(
intersectionFeature.osm_ids.map(id => cache.getFeatureByOsmId(id))
).then(roads => new Set(
roads
.filter((r) => r && r.properties && r.properties.name !== undefined)
.map((r) => r!.properties!.name)
));
}

0 comments on commit f928de0

Please sign in to comment.