Skip to content

Commit

Permalink
Split BeaconAudio from BeaconState; add tests
Browse files Browse the repository at this point in the history
  • Loading branch information
steinbro committed Nov 23, 2024
1 parent ebcbf9d commit 26838c1
Show file tree
Hide file tree
Showing 9 changed files with 230 additions and 180 deletions.
6 changes: 3 additions & 3 deletions src/components/BeaconController.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,14 @@ import { beacon } from '../state/beacon';
const toggleBeacon = () => {
if (beacon.enabled) {
beacon.stop();
beacon.disable();
} else {
beacon.start();
beacon.enable();
}
};
const clearBeacon = () => {
beacon.stop();
beacon.disable();
beacon.clear();
};
</script>
Expand Down
2 changes: 1 addition & 1 deletion src/components/CalloutList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const startBeacon = (e: MouseEvent) => {
latitude: +target.getAttribute('data-latitude')!,
longitude: +target.getAttribute('data-longitude')!
});
beacon.start();
beacon.enable();
};
</script>

Expand Down
4 changes: 2 additions & 2 deletions src/components/WelcomeScreen.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
</template>

<script setup>
import { beacon } from '../state/beacon';
import { initializeBeaconAudio } from '../state/beacon_audio';
import { useDeviceOrientation } from "../composables/compass";
import { myLocation } from '../state/location';
import { initializeAudioQueue, playSpatialSpeech } from '../state/audio';
Expand Down Expand Up @@ -39,7 +39,7 @@ const removewall = () => {
}
// Start audio context for beacon effects
beacon.initialize();
initializeBeaconAudio();
// Report to parent that we're ready
isVisible.value = false;
Expand Down
2 changes: 1 addition & 1 deletion src/composables/announcer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ function useAnnouncer() {
if (roadNames.size > 1) {
return "Intersection: " + [...roadNames].join(", ");
}
}) || "";
});
break;
case "bus_stop":
//TODO
Expand Down
41 changes: 33 additions & 8 deletions src/state/beacon.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect } from "chai";
import { beacon, isOnCourse } from "./beacon";
import { beacon, isNearby, isOnCourse } from "./beacon";
import { myLocation } from '../state/location';

describe("beacon", () => {
Expand All @@ -9,16 +9,41 @@ describe("beacon", () => {
latitude: 38.889444,
longitude: -77.035278,
});
beacon.enabled = true;
beacon.enable();

// US Capitol (due east of monument)
myLocation.setLocation(38.889722, -77.008889);
// Facing west
myLocation.setHeading(-90.0);
expect(isOnCourse.value).to.be.true;
// Various heading roughly facing west
[-90.0, -95.0, -85.0].forEach(heading => {
myLocation.setHeading(heading);
expect(isOnCourse.value).to.be.true;
});

// Various headings facing any other direction
[0.0, 90.0, 180.0, -130.0, -50.0].forEach(heading => {
myLocation.setHeading(heading);
expect(isOnCourse.value).to.be.false;
});
});

it("should recognize nearby threshold", () => {
beacon.set({
name: "Washington Monument",
latitude: 38.889444,
longitude: -77.035278,
});
beacon.enable();

// US Capitol (due east of monument)
myLocation.setLocation(38.889722, -77.008889);
expect(isNearby.value).to.be.false;

// Facing north
myLocation.setHeading(0.0);
expect(isOnCourse.value).to.be.false;
myLocation.setLocation(
beacon.location!.latitude,
beacon.location!.longitude
);
expect(isNearby.value).to.be.true;
//FIXME Beacon should be auto-disabled when we're nearME
//expect(beacon.enabled).to.be.false;
});
});
188 changes: 27 additions & 161 deletions src/state/beacon.ts
Original file line number Diff line number Diff line change
@@ -1,73 +1,11 @@
// Copyright (c) Daniel W. Steinbrook.
// with many thanks to ChatGPT

import { point } from '@turf/turf';
import { computed, reactive, watch } from 'vue';
import { distanceTo, normalizedRelativePositionTo } from '../state/location';
import { audioQueue, createPanner, PannerNodeWithCoordinates } from '../state/audio';

const Classic_OnAxis_wav = new URL("/assets/sounds/beacons/Classic/Classic_OnAxis.wav", import.meta.url).href;
const Classic_OffAxis_wav = new URL("/assets/sounds/beacons/Classic/Classic_OffAxis.wav", import.meta.url).href;
const sense_mobility_wav = new URL("/assets/sounds/sense_mobility.wav", import.meta.url).href;
const SS_beaconFound2_48k_wav = new URL("/assets/sounds/SS_beaconFound2_48k.wav", import.meta.url).href;

const onCourseAngle = 30; // degrees +/- Y axis
const foundProximityMeters = 10; // proximity to auto-stop beacon
const announceEveryMeters = 50;

/*
For smooth transitions between "on" and "off" beacons, we keep two audio
sources constantly looping, and selectively mute one or the other. On iOS
Safari, we can't directly set the volume of an audio element, so we use
gain nodes instead. A panner node is used to create spatial audio.
The resulting audio graph looks something like this:
source -> gain -> panner <- gain <- source
|
V
output
*/
class BeaconAudio {
audioContext: AudioContext;
panner: PannerNodeWithCoordinates;
onCourseAudio: HTMLAudioElement;
offCourseAudio: HTMLAudioElement;
onCourseGain: GainNode;
offCourseGain: GainNode;

// In some browsers, audio initialization needs to be triggerred by a user action
constructor() {
if ('AudioContext' in window) {
this.audioContext = new AudioContext();
} else if ('webkitAudioContext' in window) {
this.audioContext = new (window as any).webkitAudioContext();
} else {
throw new Error('AudioContext is not supported in this browser.');
}

this.panner = createPanner(this.audioContext);

this.onCourseAudio = new Audio(Classic_OnAxis_wav);
this.offCourseAudio = new Audio(Classic_OffAxis_wav);
this.onCourseAudio.loop = true;
this.offCourseAudio.loop = true;

this.onCourseGain = this.audioContext.createGain();
this.offCourseGain = this.audioContext.createGain();

this.audioContext
.createMediaElementSource(this.onCourseAudio)
.connect(this.onCourseGain);
this.audioContext
.createMediaElementSource(this.offCourseAudio)
.connect(this.offCourseGain);

this.onCourseGain.connect(this.panner);
this.offCourseGain.connect(this.panner);
this.panner.connect(this.audioContext.destination);
}
}

interface BeaconLocation {
name: string;
Expand All @@ -76,38 +14,19 @@ interface BeaconLocation {
}

interface BeaconState {
audio: BeaconAudio | undefined;
location: BeaconLocation | null;
lastAnnouncedDistance: number | null;
enabled: boolean;

initialize: () => void;
getAudio: () => BeaconAudio;
set: (location: BeaconLocation) => void;
clear: () => void;
start: () => void;
stop: () => void;
enable: () => void;
disable: () => void;
}
export const beacon = reactive<BeaconState>({
location: null,
lastAnnouncedDistance: null,
enabled: false,
audio: undefined,

// Ensures we only initialize audio once
initialize() {
if (this.audio === undefined) {
this.audio = new BeaconAudio();
}
},

// Ensures that we don't try any beacon actions before audio initialization
getAudio(): BeaconAudio {
if (this.audio === undefined) {
throw new Error("Beacon audio uninitialized");
}
return this.audio;
},

set(location: BeaconLocation) {
this.location = location;
Expand All @@ -116,113 +35,60 @@ export const beacon = reactive<BeaconState>({

clear() { this.location = null; },

start() {
enable() {
this.enabled = true;
looper.start();
},

stop() {
disable() {
this.enabled = false;
looper.stop();
},
});

// Turf.js point of the beacon's location
const sourceLocation = computed(() => {
if (beacon.enabled && beacon.location) {
if (beacon.location) {
return point([beacon.location.longitude, beacon.location.latitude]);
}
});

// Distance we are currently from the beacon
const distanceMeters = computed(() => {
export const distanceMeters = computed(() => {
if (sourceLocation.value) {
return distanceTo.value(sourceLocation.value, { units: "meters", });
}
});

// Beacon's X/Y coordinates relative to us (standing at the origin, looking up Y axis)
const relativePosition = computed(() => {
export const relativePosition = computed(() => {
if (sourceLocation.value) {
return normalizedRelativePositionTo.value(sourceLocation.value);
}
});

// Set the beacon sound effect spatial position.
watch(relativePosition, (newValue, oldVAlue) => {
if (beacon.enabled && beacon.audio && newValue) {
beacon.getAudio().panner.setCoordinates(newValue.x, newValue.y);
}
});

// True if we are roughly facing the beacon, +/- onCourseAngle
export const isOnCourse = computed(() => {
if (beacon.enabled && relativePosition.value) {
const angle = Math.atan2(
relativePosition.value.x,
relativePosition.value.y
) * 180 / Math.PI;
return (Math.abs(angle) < onCourseAngle);
} else {
return false;
}
return (
relativePosition.value &&
onCourseAngle > Math.abs(
Math.atan2(
relativePosition.value.x,
relativePosition.value.y
) * 180 / Math.PI
)
);
});

// Loop beacon audio, which changes based on how on-/off-course we are.
interface Looper {
intervalId: number | undefined;
start: () => void;
stop: () => void;
}
const looper: Looper = {
intervalId: undefined,
async start() {
let audio = beacon.getAudio();
// Resume the audio context (especially needed for Safari)
if (audio.audioContext.state === 'suspended') {
await audio.audioContext.resume();
}
audio.onCourseAudio.play();
audio.offCourseAudio.play()
// Switch between on/off-course effects no more than once per second
this.intervalId = window.setInterval(() => {
if (isOnCourse.value) {
audio.onCourseGain.gain.setValueAtTime(1, audio.audioContext.currentTime);
audio.offCourseGain.gain.setValueAtTime(0, audio.audioContext.currentTime);
} else {
audio.onCourseGain.gain.setValueAtTime(0, audio.audioContext.currentTime);
audio.offCourseGain.gain.setValueAtTime(1, audio.audioContext.currentTime);
}
}, 1000);
},
stop() {
let audio = beacon.getAudio();
window.clearInterval(this.intervalId);
audio.onCourseAudio.pause();
audio.offCourseAudio.pause();
},
};
// True if we are within foundProximityMeters of beacon
export const isNearby = computed(() => {
return (
distanceMeters.value !== undefined &&
distanceMeters.value < foundProximityMeters
);
});

// Announce the beacon distance periodically
watch(distanceMeters, (newValue, oldValue) => {
if (!beacon.enabled || !beacon.audio || typeof newValue !== "number") {
return;
} else if(newValue < foundProximityMeters) {
// Stop the beacon when we're within the threshold.
beacon.stop();
new Audio(SS_beaconFound2_48k_wav).play();
} else if (
beacon.lastAnnouncedDistance === null ||
Math.abs(beacon.lastAnnouncedDistance - newValue) > announceEveryMeters
) {
// We're closer/further by some threshold -- announce distance
// Only announce if not actively playing something else (distance would be stale if queued)
if (!audioQueue.isPlaying) {
beacon.lastAnnouncedDistance = newValue;
audioQueue.addToQueue({ soundUrl: sense_mobility_wav });
audioQueue.addToQueue({
text: `Beacon: ${newValue.toFixed(0)} meters`,
});
}
// Disable beacon when nearby
watch(isNearby, (newValue, oldValue) => {
if (newValue === true && oldValue === false) {
beacon.disable();
}
});
Loading

0 comments on commit 26838c1

Please sign in to comment.