diff --git a/src/components/MainModeSelector.vue b/src/components/MainModeSelector.vue index 569836b..dfea32a 100644 --- a/src/components/MainModeSelector.vue +++ b/src/components/MainModeSelector.vue @@ -25,7 +25,7 @@ import mode_enter_wav from "/assets/sounds/mode_enter.wav"; import { ref } from 'vue'; import { audioQueue } from '../state/audio'; -import useAnnouncer from '../composables/announcer.js'; +import useAnnouncer from '../composables/announcer'; import { myLocation } from '../state/location'; const announcer = useAnnouncer(); diff --git a/src/composables/announcer.js b/src/composables/announcer.js deleted file mode 100644 index 5999fb7..0000000 --- a/src/composables/announcer.js +++ /dev/null @@ -1,243 +0,0 @@ -// Copyright (c) Daniel W. Steinbrook. -// with many thanks to ChatGPT - -import sense_mobility_wav from "/assets/sounds/sense_mobility.wav"; -import sense_poi_wav from "/assets/sounds/sense_poi.wav"; - -import { centroid } from '@turf/centroid'; -import { nearestPointOnLine } from '@turf/nearest-point-on-line'; -import cache from "../state/cache"; -import { audioQueue } from '../state/audio'; -import { enumerateTilesAround } from "../composables/tile"; -import { watch } from 'vue'; -import { myLocation, myTurfPoint, distanceTo } from '../state/location'; - -function useAnnouncer() { - // Avoid repeating myself, by maintaining a list of the most recent POIs announced - const spokenRecently = { - keys: new Set(), // for quick lookups - queue: [], // for first in, first out - max_size: 100, - // feature can have multiple osm_ids (e.g. intersections) - key: (osm_ids) => osm_ids.join("|"), - - add: function (osm_ids) { - if (this.keys.size > this.max_size) { - const oldestKey = this.queue.shift(); - this.keys.delete(oldestKey); - } - this.keys.add(this.key(osm_ids)); - this.queue.push(this.key(osm_ids)); - }, - - has: function (osm_ids) { - return this.keys.has(this.key(osm_ids)); - }, - }; - - function playSoundAndSpeech(soundUrl, text, sourceLocation, includeDistance) { - audioQueue.addToQueue({ - soundUrl: soundUrl, - location: sourceLocation, - }); - audioQueue.addToQueue({ - text: text, - location: sourceLocation, - includeDistance: includeDistance, - }); - } - - // Get names of intersecting roads by looking up each road individually - function getRoadNames(intersectionFeature) { - return Promise.all( - intersectionFeature.osm_ids.map((id) => cache.getFeatureByOsmId(id)) - ).then( - (roads) => - new Set( - roads - .filter((r) => r && r.properties.name !== undefined) - .map((r) => r.properties.name) - ) - ); - } - - // Annotate GeoJSON feature with attributes and methods used for spatial audio callouts - function announceable(feature) { - // Method for computing distance depends on geometry, e.g. finding nearest - // point on a line, or to the centroid of a polygon. - switch (feature.geometry.type) { - case "LineString": // e.g. roads - feature.point = nearestPointOnLine( - feature, - myTurfPoint.value, - { units: "meters" } - ); - break; - default: // buildings, landmarks, etc. - feature.point = centroid(feature.geometry); - } - feature.distance = distanceTo.value(feature.point, { - units: "meters", - }); - - //TODO for now, all callouts are POIs - feature.soundEffectUrl = sense_poi_wav; - - feature.getAudioLabel = async function () { - // 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 - return feature.properties.name; - } - }; - - // Speaks a feature if it has a non-empty audio label (returns true if so) - feature.announce = (options) => { - return feature.getAudioLabel().then((label) => { - if (label) { - spokenRecently.add(feature.osm_ids); - playSoundAndSpeech( - feature.soundEffectUrl, - label, - feature.point, - options.includeDistance - ); - return true; - } else { - return false; - } - }); - }; - - return feature; - } - - const announcer = { - nearbyFeatures: (latitude, longitude, radiusMeters) => { - 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(announceable) - // 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, longitude, radiusMeters) => { - 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.name - ) - ); - }, - - // Announce all speakable nearby features - // Returns true if anything was queued for speaking - calloutAllFeatures: (latitude, longitude) => { - // Use 2x wider radius than standard location updates - const radiusMeters = 2 * myLocation.radiusMeters; - return announcer - .nearbyFeatures(latitude, longitude, radiusMeters) - .then((fs) => { - return Promise.all( - fs.map((f) => f.announce({ includeDistance: true })) - ).then((willAnnounce) => willAnnounce.some((x) => x)); - }); - }, - - // Same as above, but says a message if no features were announced - calloutAllFeaturesOrSayNoneFound: (latitude, longitude) => { - announcer - .calloutAllFeatures(latitude, longitude) - .then((anythingToSay) => { - if (!anythingToSay) { - audioQueue.addToQueue({ - text: "Nothing to call out right now", - }); - } - }); - }, - - // Announce only features not already called out (useful for continuous tracking) - calloutNewFeatures: (latitude, longitude) => { - const radiusMeters = myLocation.radiusMeters; - announcer.nearbyFeatures(latitude, longitude, radiusMeters).then((fs) => { - // Omit features already announced - fs.filter((f) => !spokenRecently.has(f.osm_ids)).forEach((f) => - f.announce({ includeDistance: false }) - ); - }); - }, - - calloutNearestRoad: (latitude, longitude) => { - const radiusMeters = myLocation.radiusMeters; - announcer.nearbyRoads(latitude, longitude, radiusMeters).then((roads) => { - if (roads.length > 0) { - playSoundAndSpeech( - sense_mobility_wav, - `Nearest road: ${roads[0].properties.name}`, - roads[0].point, - true - ); - } - }); - }, - - unwatch: null, - startWatching: () => { - announcer.unwatch = watch(myLocation, (newValue, oldValue) => { - return announcer.calloutNewFeatures( - myLocation.latitude, - myLocation.longitude - ); - }) - }, - stopWatching: () => { - if (announcer.unwatch) { - announcer.unwatch(); - }; - }, - }; - - return announcer; -} - -export default useAnnouncer; \ No newline at end of file diff --git a/src/composables/announcer.ts b/src/composables/announcer.ts new file mode 100644 index 0000000..eaa0133 --- /dev/null +++ b/src/composables/announcer.ts @@ -0,0 +1,276 @@ +// 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 { audioQueue } from '../state/audio'; +import { enumerateTilesAround } from "../composables/tile"; +import { watch } from 'vue'; +import { myLocation, myTurfPoint, distanceTo } from '../state/location'; + +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; + distance: number; + soundEffectUrl: string; + getAudioLabel: () => Promise; + announce: (options: { includeDistance: boolean }) => Promise; +} + +interface Announcer { + nearbyFeatures: (latitude: number, longitude: number, radiusMeters: number) => Promise; + nearbyRoads: (latitude: number, longitude: number, radiusMeters: number) => Promise; + calloutAllFeatures: (latitude: number, longitude: number) => Promise; + calloutAllFeaturesOrSayNoneFound: (latitude: number, longitude: number) => void; + calloutNewFeatures: (latitude:number, longitude:number) => void; + calloutNearestRoad: (latitude: number, longitude: number) => void; + startWatching: () => void; + stopWatching: () => void; + unwatch?: () => void; +} + +interface RecentQueue { + keys: Set; + queue: string[]; + max_size: number; + key: (osm_ids: number[]) => string; + add: (osm_ids: number[]) => void; + has: (osm_ids: number[]) => boolean; +} + +function useAnnouncer() { + // Avoid repeating myself, by maintaining a list of the most recent POIs announced + const spokenRecently: RecentQueue = { + keys: new Set(), // for quick lookups + queue: [], // for first in, first out + max_size: 100, + // feature can have multiple osm_ids (e.g. intersections) + key: (osm_ids: number[]) => osm_ids.join("|"), + + add: function (osm_ids: number[]) { + if (this.keys.size > this.max_size) { + const oldestKey = this.queue.shift()!; + this.keys.delete(oldestKey); + } + this.keys.add(this.key(osm_ids)); + this.queue.push(this.key(osm_ids)); + }, + + has: function (osm_ids: number[]): boolean { + return this.keys.has(this.key(osm_ids)); + }, + }; + + function playSoundAndSpeech(soundUrl: string, text: string, sourceLocation: Feature, includeDistance: boolean) { + audioQueue.addToQueue({ + soundUrl: soundUrl, + location: sourceLocation, + }); + audioQueue.addToQueue({ + text: text, + location: sourceLocation, + includeDistance: includeDistance, + }); + } + + // Get names of intersecting roads by looking up each road individually + function getRoadNames(intersectionFeature: SoundscapeFeature): Promise> { + return Promise.all( + intersectionFeature.osm_ids.map((id: number) => cache.getFeatureByOsmId(String(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, + 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 { + // 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 => { + 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; + } + + const announcer: Announcer = { + nearbyFeatures: (latitude: number, longitude: number, radiusMeters: number): Promise => { + 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 => { + 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 => { + // Use 2x wider radius than standard location updates + const radiusMeters = 2 * myLocation.radiusMeters; + return announcer + .nearbyFeatures(latitude, longitude, radiusMeters) + .then((fs) => { + return Promise.all( + fs.map((f) => f.announce({ includeDistance: true })) + ).then((willAnnounce) => willAnnounce.some((x) => x)); + }); + }, + + // Same as above, but says a message if no features were announced + calloutAllFeaturesOrSayNoneFound: (latitude: number, longitude: number): void => { + announcer + .calloutAllFeatures(latitude, longitude) + .then((anythingToSay) => { + if (!anythingToSay) { + audioQueue.addToQueue({ + text: "Nothing to call out right now", + }); + } + }); + }, + + // 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) => { + // Omit features already announced + fs.filter((f) => !spokenRecently.has(f.osm_ids)).forEach((f) => + f.announce({ includeDistance: false }) + ); + }); + }, + + calloutNearestRoad: (latitude: number, longitude: number) => { + const radiusMeters = myLocation.radiusMeters; + announcer.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, + true + ); + } + }); + }, + + startWatching: () => { + announcer.unwatch = watch(myLocation, (newValue, oldValue) => { + if (newValue.latitude && newValue.longitude) { + return announcer.calloutNewFeatures( + newValue.latitude, + newValue.longitude + ); + } + }) + }, + stopWatching: () => { + if (announcer.unwatch) { + announcer.unwatch(); + }; + }, + }; + + return announcer; +} + +export default useAnnouncer; \ No newline at end of file diff --git a/src/composables/tile.ts b/src/composables/tile.ts index b72358e..3dc75bc 100644 --- a/src/composables/tile.ts +++ b/src/composables/tile.ts @@ -2,8 +2,8 @@ // with many thanks to ChatGPT import { point, buffer, bbox } from '@turf/turf'; -import { BBox, Feature } from "geojson"; -import cache from '../state/cache' +import { BBox } from "geojson"; +import { cache, SoundscapeFeature } from '../state/cache' import config from '../config' export const zoomLevel = 16; @@ -20,7 +20,7 @@ interface TileCoordinates { interface Tile extends TileCoordinates { key: string; load: () => Promise; - getFeatures: () => Promise; + getFeatures: () => Promise; } // Function to create a half-kilometer bounding box around a point @@ -115,7 +115,7 @@ function createTile(x: number, y: number, z: number): Tile { tilesInProgressOrDone.delete(tile.key); } }, - getFeatures: async function(): Promise { + getFeatures: async function(): Promise { return cache.getFeatures(tile.key); }, }; diff --git a/src/state/cache.ts b/src/state/cache.ts index 97b91f4..38d1a2c 100644 --- a/src/state/cache.ts +++ b/src/state/cache.ts @@ -1,12 +1,14 @@ // Copyright (c) Daniel W. Steinbrook. // with many thanks to ChatGPT -interface GeoJSONFeature { - type: 'Feature'; - geometry: any; // Could be more specific with GeoJSON types - properties: any; +import { Feature } from 'geojson'; + +// Tile server's custom extensions to GeoJSON Features +export type SoundscapeFeature = Feature & { + feature_type: string; + feature_value: string; + osm_ids: number[]; tile?: string; - osm_ids?: string[]; id?: number; } @@ -73,7 +75,7 @@ async function clearObjectStore(objectStoreName: string): Promise { }; } -const cache = { +export const cache = { db: null as IDBDatabase | null, // to be populated on first request clear: function(): void { @@ -158,7 +160,7 @@ const cache = { }, // Function to add GeoJSON feature to the cache - addFeature: function(feature: GeoJSONFeature, tile: string): Promise { + addFeature: function(feature: SoundscapeFeature, tile: string): Promise { return new Promise(async (resolve, reject) => { if (!cache.db) { cache.db = await openDatabase(); @@ -181,7 +183,7 @@ const cache = { }); }, - getFeatures: async function(tileKey: string): Promise { + getFeatures: async function(tileKey: string): Promise { return new Promise(async (resolve, reject) => { if (!cache.db) { cache.db = await openDatabase(); @@ -193,13 +195,13 @@ const cache = { const range = IDBKeyRange.only(tileKey); const request = tileIndex.openCursor(range); - const features: GeoJSONFeature[] = []; + const features: SoundscapeFeature[] = []; request.onsuccess = function(event: Event) { const cursor = (event.target as IDBRequest).result; if (cursor) { - features.push(cursor.value as GeoJSONFeature); + features.push(cursor.value as SoundscapeFeature); cursor.continue(); } else { resolve(features); @@ -239,7 +241,7 @@ const cache = { }; }, - getFeatureByOsmId: async function(osm_id: string): Promise { + getFeatureByOsmId: async function(osm_id: string): Promise { // Returns at most one feature, matching a single OSM ID (i.e. a road, not // intersectionss involving that road). return new Promise(async (resolve, reject) => { @@ -257,7 +259,7 @@ const cache = { const cursor = (event.target as IDBRequest).result; if (cursor) { - const feature = cursor.value as GeoJSONFeature; + const feature = cursor.value as SoundscapeFeature; if (feature.osm_ids?.length === 1) { resolve(feature); } else { diff --git a/src/views/GPXView.vue b/src/views/GPXView.vue index 62866c4..a7c039d 100644 --- a/src/views/GPXView.vue +++ b/src/views/GPXView.vue @@ -56,7 +56,7 @@ import CalloutList from '../components/CalloutList.vue'; import InputSpinner from '../components/InputSpinner.vue'; import MapDisplay from '../components/MapDisplay.vue'; -import useAnnouncer from '../composables/announcer.js'; +import useAnnouncer from '../composables/announcer'; import useGPX from '../composables/gpx'; import { beacon } from '../state/beacon'; import cache from '../state/cache';