diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e43b0f9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+.DS_Store
diff --git a/gyro/Track1Fade.mp3 b/gyro/Track1Fade.mp3
new file mode 100644
index 0000000..3a68565
Binary files /dev/null and b/gyro/Track1Fade.mp3 differ
diff --git a/gyro/Track2Fade.mp3 b/gyro/Track2Fade.mp3
new file mode 100644
index 0000000..10da8c4
Binary files /dev/null and b/gyro/Track2Fade.mp3 differ
diff --git a/gyro/Track3Fade.mp3 b/gyro/Track3Fade.mp3
new file mode 100644
index 0000000..a376e4d
Binary files /dev/null and b/gyro/Track3Fade.mp3 differ
diff --git a/gyro/Track4Fade.mp3 b/gyro/Track4Fade.mp3
new file mode 100644
index 0000000..ce4a0e1
Binary files /dev/null and b/gyro/Track4Fade.mp3 differ
diff --git a/gyro/Track5Fade.mp3 b/gyro/Track5Fade.mp3
new file mode 100644
index 0000000..47c75eb
Binary files /dev/null and b/gyro/Track5Fade.mp3 differ
diff --git a/gyro/Track6Fade.mp3 b/gyro/Track6Fade.mp3
new file mode 100644
index 0000000..ff4fd60
Binary files /dev/null and b/gyro/Track6Fade.mp3 differ
diff --git a/gyro/index.html b/gyro/index.html
index d50c7c9..9e61387 100644
--- a/gyro/index.html
+++ b/gyro/index.html
@@ -4,16 +4,68 @@
-
- Is it working?
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/gyro/index.js b/gyro/index.js
index 1bd90d9..662bde1 100644
--- a/gyro/index.js
+++ b/gyro/index.js
@@ -1,42 +1,260 @@
"use strict";
(() => {
- const ctx = canvas.getContext("2d");
- const scale = Math.min(canvas.width, canvas.height) / 2;
- ctx.setTransform(
- scale, 0,
- 0, -scale,
- canvas.width / 2, canvas.height / 2
- );
+ const TAU = 2 * Math.PI;
- ctx.fillStyle = util.rgb(1, 0, 0);
- ctx.fillRect(-0.5, -0.5, 1, 1);
+ // Build buffers b1 and b2 such that
+ // the original buffer can be played
+ // in a loop where it overlaps itself
+ // by the duration specified in overlap.
+ // If the overlap is two units, the
+ // resulting buffers look like this:
+ //
+ // buffer: OOBBBBBOO
+ // b1: OOBBBBBOO-----
+ // b2: -----OOBBBBBOO
+ //
+ // B: Buffer data (plays normally)
+ // O: Affected by overlap
+ // -: Inserted silence
+ //
+ // By starting b2 overlap seconds after
+ // b1, overlapping looping playback is
+ //
+ //
+ // OOBBBBBOO-----OOBBBBBOO-----
+ // -----OOBBBBBOO-----OOBBBBBOO
+ function buildOverlappingBuffers (audioCtx, buffer, overlap) {
+ const nonOverlapDuration = buffer.duration - 2 * overlap;
+ const nonOverlapSamples = Math.floor(nonOverlapDuration * buffer.sampleRate);
+
+ const totalLength = buffer.length + nonOverlapSamples;
+
+ const b1 = audioCtx.createBuffer(buffer.numberOfChannels, totalLength, buffer.sampleRate);
+ const b2 = audioCtx.createBuffer(buffer.numberOfChannels, totalLength, buffer.sampleRate);
+
+ // TODO: May be faster using the copy methods on AudioBuffer
+ for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
+ const channelBuffer = buffer.getChannelData(channel);
+ const b1ChannelBuffer = b1.getChannelData(channel);
+ const b2ChannelBuffer = b2.getChannelData(channel);
+
+ for (let i = 0; i < channelBuffer.length; i++) {
+ b1ChannelBuffer[i] = channelBuffer[i];
+ b2ChannelBuffer[nonOverlapSamples + i] = channelBuffer[i];
+ }
+ }
+
+ return { b1, b2 };
+ }
+
+ function baseGain (peak, rotH) {
+ const diff = Math.abs(util.diffAngles(peak, rotH));
+
+ if (diff < TAU / 8) {
+ return 1;
+ } else if (diff < TAU / 8 + TAU / 16) {
+ return 1 - (diff - TAU / 8) / (TAU / 16);
+ } else {
+ return 0;
+ }
+ }
+
+ function gain (peak, rotH, rotV) {
+ return baseGain(peak, rotH) * (1 - rotV / (Math.PI / 2));
+ }
+
+ function drawRing (ctx, r, f) {
+ const n = 48;
+ for (let i = 0; i < n; i++) {
+ const a = Math.PI * 2 * i / n;
+ const a_ = Math.PI * 2 * (i + 1) / n;
+ const g = f((a + a_) / 2);
+ if (g === 0) continue;
+ ctx.lineWidth = 0.1 * g;
+ ctx.beginPath();
+ ctx.arc(0, 0, r, a, a_);
+ ctx.stroke();
+ }
+ }
+
+ function loadTrack (audioCtx, trackHref) {
+ return (
+ fetch(trackHref)
+ .then(response => response.arrayBuffer())
+ .then(arrayBuffer => audioCtx.decodeAudioData(arrayBuffer))
+ .then(track => {
+ const { b1, b2 } = buildOverlappingBuffers(audioCtx, track, 2);
+ const gainNode = audioCtx.createGain();
+
+ const s1 = audioCtx.createBufferSource();
+ s1.buffer = b1;
+ s1.loop = true;
+ s1.connect(gainNode);
+ s1.start();
+
+ const s2 = audioCtx.createBufferSource();
+ s2.buffer = b2;
+ s2.loop = true;
+ s2.connect(gainNode);
+ s2.start(audioCtx.currentTime + 0.5);
+
+ return gainNode;
+ })
+ );
+ }
+
+ function setupAudioStuff (trackHrefs) {
+ const audioCtx = new AudioContext();
+ return (
+ Promise.all(trackHrefs.map((trackHref, i) =>
+ loadTrack(audioCtx, trackHref)
+ .then((track) => {
+ log.innerHTML += `* Track ${i}\n`;
+ return track;
+ })
+ ))
+ .then(gains => {
+ log.innerHTML += `All tracks received\n`;
+ return {
+ ctx: audioCtx,
+ gains,
+ };
+ })
+ );
+ }
+
+ function waitForEvent (target, eventKey, waitTime) {
+ return new Promise((resolve, reject) => {
+ const waitTimeout = setTimeout(() => reject(new Error(`${eventKey} didnt fire after ${waitTime}ms`)), waitTime);
+
+ target.addEventListener(eventKey, (e) => {
+ clearTimeout(waitTimeout);
+ resolve(e);
+ });
+ });
+ }
+
+ function setupGyroscope () {
+ return (
+ util.getGyroPermission()
+ .then(response => {
+ if (response !== "granted") {
+ throw new Error("gyroscope permission denied");
+ }
+
+ return waitForEvent(window, "deviceorientation", 10000);
+ })
+ );
+ }
+
+ function setupCanvas () {
+ const ctx = canvas.getContext("2d");
+ const scale = Math.min(canvas.width, canvas.height) / 2;
+ ctx.setTransform(
+ scale, 0,
+ 0, -scale,
+ canvas.width / 2, canvas.height / 2
+ );
+
+ return ctx;
+ }
+
+ function render (ctx, audio, rotH, rotV) {
+ for (const gainNode of audio.gains) {
+ gainNode.gain.value = 0;
+ }
+
+ const rotVScaled = 1 - rotV / (Math.PI / 2);
+ audio.gains[0].gain.value = rotVScaled < 0.75 ? 1 : 1 - (rotVScaled - 0.75) / 0.25;
+ audio.gains[1].gain.value = gain(0, rotH, rotV);
+ audio.gains[2].gain.value = gain(Math.PI / 2, rotH, rotV);
+ audio.gains[3].gain.value = gain(Math.PI, rotH, rotV);
+ audio.gains[4].gain.value = gain(Math.PI * (3 / 2), rotH, rotV);
+ audio.gains[5].gain.value = rotVScaled < 0.75 ? 0 : (rotVScaled - 0.75) / 0.25;
+
+ ctx.clearRect(-1, -1, 2, 2);
+
+ ctx.strokeStyle = "red";
+ drawRing(ctx, 0.8, (a) => gain(0, a, rotV));
+
+ ctx.strokeStyle = "blue";
+ drawRing(ctx, 0.7, (a) => gain(Math.PI / 2, a, rotV));
+
+ ctx.strokeStyle = "green";
+ drawRing(ctx, 0.8, (a) => gain(Math.PI, a, rotV));
+
+ ctx.strokeStyle = "orange";
+ drawRing(ctx, 0.7, (a) => gain(Math.PI * (3 / 2), a, rotV));
+
+ ctx.strokeStyle = "black";
+ ctx.beginPath();
+ ctx.moveTo(0, 0);
+ ctx.lineTo(Math.cos(rotH), Math.sin(rotH));
+ ctx.stroke();
+
+ drawBar(ctx, -0.65, 0.8, 0.1, 0.2, "red", audio.gains[1].gain.value);
+ drawBar(ctx, -0.25, 0.8, 0.1, 0.2, "blue", audio.gains[2].gain.value);
+ drawBar(ctx, 0.15, 0.8, 0.1, 0.2, "green", audio.gains[3].gain.value);
+ drawBar(ctx, 0.55, 0.8, 0.1, 0.2, "orange", audio.gains[4].gain.value);
+
+ drawBar(ctx, -0.25, -1, 0.1, 0.2, "lime", audio.gains[0].gain.value);
+ drawBar(ctx, 0.15, -1, 0.1, 0.2, "grey", audio.gains[5].gain.value);
+ }
+
+ function drawBar (ctx, x, y, w, h, color, value) {
+ const hScaled = h * value;
+ ctx.fillStyle = color;
+ ctx.fillRect(x, y, w, hScaled);
+ ctx.fillStyle = "black";
+ ctx.fillRect(x, y + hScaled, w, h - hScaled);
+ }
perm.addEventListener("click", e => {
- util.getGyroPermission()
- .then((response) => {
+ setupAudioStuff([
+ "Track1Fade.mp3",
+ "Track2Fade.mp3",
+ "Track3Fade.mp3",
+ "Track4Fade.mp3",
+ "Track5Fade.mp3",
+ "Track6Fade.mp3",
+ ])
+ .then(audio => {
+ log.innerHTML += "waiting for gyroscope permissions\n";
+ return (
+ setupGyroscope()
+ .then(() => {
+ return audio;
+ })
+ );
+ })
+ .then(audio => {
+ document.body.dataset.state = "main";
+
+ for (const gainNode of audio.gains) {
+ gainNode.connect(audio.ctx.destination);
+ }
+
+ const ctx = setupCanvas();
window.addEventListener("deviceorientation", e => {
const alpha = util.deg2rad(e.alpha);
const beta = util.deg2rad(e.beta);
const gamma = util.deg2rad(e.gamma);
const [screenNormal, phi, theta] = util.toPolarCoordinates(alpha, beta, gamma);
- const theta_ = Math.abs(theta - Math.PI / 2) / Math.PI * 2;
- const phi_ = phi - Math.PI / 2;
+ // "horizontal rotation": phi offset by 90 degrees.
+ // rotH ∈ [-π, π]
+ const rotH = phi - Math.PI / 2;
+ // "vertical rotation": theta mirrored at xy plane.
+ // rotV ∈ [0, π / 2]
+ const rotV = Math.abs(theta - Math.PI / 2);
- ctx.clearRect(-1, -1, 2, 2);
-
- const g = theta_;
- ctx.fillStyle = util.rgb(255, g, g);
- ctx.fillRect(-1, -1, 2, 2);
-
- ctx.strokeStyle = "black";
- ctx.lineWidth = 0.01;
- ctx.beginPath();
- ctx.moveTo(0, 0);
- ctx.lineTo(Math.cos(phi_), Math.sin(phi_));
- ctx.stroke();
+ render(ctx, audio, rotH, rotV);
});
+ })
+ .catch(err => {
+ document.body.dataset.state = "error";
+ error.innerHTML = err;
});
}, { once: true });
})();
diff --git a/gyro/util.js b/gyro/util.js
index 3ea4a0f..3542199 100644
--- a/gyro/util.js
+++ b/gyro/util.js
@@ -28,5 +28,7 @@ const util = {
return [screenNormal, phi, theta];
},
rgb: (r, g, b) => `rgb(${255 * r}, ${255 * g}, ${255 * b})`,
+ rgba: (r, g, b, a) => `rgb(${255 * r}, ${255 * g}, ${255 * b}, ${a})`,
deg2rad: (deg) => deg / 180 * Math.PI,
+ diffAngles: (a, b) => Math.atan2(Math.sin(b - a), Math.cos(b - a)),
};