Nov 03, 2024
House Fan
Our house has one of those old, giant whole-house fans that looks like it was ripped out of an airplane and grafted into the ceiling. We love to run it on cool nights when we can leverage Sacramento’s fabled Delta breeze to maximum effect. The steady current of cool, citrus and jasmine-scented air it produces is deeply satisfying after a sun-scorched day.
It’s also the perfect noise machine, which as it happens, turned out to be a bit of a problem. Our son got so hooked on its soothing sounds that we’d wake up in the middle of 30F winter nights to the sound of our house fan on full blast. We tried subbing in various white noise machines, but he was dead set on the house fan.
I get it. I’m a can’t-sleep-without-noise person, and can attest to the fact that the sound our house fan makes is top-tier. The magic is two overlapping tones; brown noise created by the rush of air moving through the fan, and a deep, resonant thrum created by the fan vibrating the structure of our house like a tuning fork. Our house literally sings us to sleep.
So instead of playing a round of immovable object vs unstoppable force, I decided to figure out an accommodation. “Accommodation” is a word you hear frequently in special education and disability circles (our son is on the spectrum), and it’s a concept we work into day-to-day life. Briefly, an accommodation is a modification or adjustment to the status quo for a person with a given disability, with a goal of increasing access or equity. An accommodation might be more time to take a test, video off on a Zoom call, a ramp for someone using a wheelchair or walker, etc.
The accommodation in this case? A house fan emulator.
I sampled our house fan’s sound, built a simple offline PWA to play it, loaded it on an old iPhone, and hooked it up to a bass-friendly Ikea bluetooth speaker. To make things extra immersive, I added a “rumble pack” on his bed frame (an aquarium air pump).
The overall effect was surprisingly realistic. Like, can’t tell the difference in a blind test realistic. Sufficed to say, the accommodation was accepted. Happy kid, happy dad.
Our son uses it whenever weather doesn’t permit the real deal, and I use it (sans rumble pack) whenever I travel. You can use it too. It’ll work on your phone, offline, forever. No ads, no tracking, just house fan.
Check it out at housefan.aaadaaam.com.
Building House Fan
In theory I could have just sampled a long recording of our house fan, but it would have been harder to use, and let’s be honest, it was a reason to build something fun.
I’ve built just-for-me iOS apps before, but I didn’t feel like dealing with app store nonsense, so I went the PWA route. Plus, I wanted to see what kind of shape PWA background audio on iOS was in (spoiler: not great).
This is a tiny app that I don’t want to maintain, so it’s just vanilla markup, CSS, and JS that’ll work until the heat death of the internet. Here’s what the markup looks like:
<div class="layout">
<sound-player>
<figure class="fan" aria-label="a pixel art house fan">
<div class="frame"></div>
<div class="louvers">
<div class="louver"></div>
<div class="louver"></div>
<div class="louver"></div>
<div class="louver"></div>
<div class="louver"></div>
<div class="louver"></div>
</div>
<div class="blades"></div>
</figure>
<audio src="/sounds/house-fan-60.mp3" loop preload="auto"></audio>
<button aria-label="play" class="button" type="button" aria-pressed="false" aria-label="play">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</button>
</sound-player>
</div>
A graphic of a house fan broken up into parts for animation purposes, an audio element that points to the recording, and a button. The audio
element is important here. If you want background audio to work on iOS – critical for this specific use case – you must to use an audio element. I spent too long working with fancy looping audio libraries before I figured this out.
All the functionality is handled by the sound-player
custom element:
export default class SoundPlayer extends HTMLElement {
playable() {
this.toggle.addEventListener('click', (e) => {
this.update();
});
this.fan.addEventListener('click', (e) => {
this.update();
});
}
update() {
const pressed = this.toggle.getAttribute('aria-pressed') === 'true';
this.toggle.setAttribute('aria-pressed', String(!pressed));
this.toggle.classList.toggle('--active');
if (this.audio.paused) {
this.audio.play();
this.toggle.setAttribute('aria-label', 'pause');
} else {
this.audio.pause();
this.toggle.setAttribute('aria-label', 'play');
}
}
loop() {
this.audio.addEventListener("timeupdate", () => {
if (this.audio.currentTime > this.audio.duration - 1) {
this.audio.currentTime = 1;
this.audio.play();
}
});
}
connectedCallback() {
this.fan = this.querySelector('figure')
this.blades = this.querySelector('.blades')
this.toggle = this.querySelector('button');
this.audio = this.querySelector('audio');
this.playable();
this.loop();
}
}
customElements.define('sound-player', SoundPlayer);
It does 3 things:
- Adds a CSS hook for when the sound is playing for style/animation purposes.
- Adds event listeners to the fan and button to play/pause the audio and update the UI.
- Adds an event listener to the audio that sets the time back to the beginning when there is 1s of play time left.
CSS-wise, there’s a few things worth pointing out when it comes to working with small-scale pixel images like this. Here’s the styling for the fan blades, which covers them all:
.fan {
...
.blades {
position: absolute;
inset: 0;
z-index: 1;
aspect-ratio: 1/1;
background-image: url("/assets/blades.gif");
background-size: 200% 100%;
background-position: 0 0;
background-repeat: no-repeat;
image-rendering: pixelated;
&.--active {
animation: fan-spin 0.1s step-start infinite;
@media (prefers-reduced-motion) {
animation: none;
}
}
}
It uses a common image sprite + background-position
approach to animate through frames. Using a steps animation timing function is important here, since it gives you that hard cut you’d expect for frame-based animation. image-rendering: pixelated;
is also critical here, otherwise the tiny sprites (some as small as 16x20 px) would be a blurry mess.
Making it work as an offline progressive web app took a few additions. Here’s the relevant portion of the head
tag:
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="theme-color" content="#999" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#ddd" media="(prefers-color-scheme: light)">
<link rel="manifest" href="/app.webmanifest">
<link rel="apple-touch-icon" href="/assets/icons/favicon-196x196.png">
</head>
The meta tags are mostly about getting a chromeless, full screen canvas, which makes for a more “app-like” experience. A manifest is a requirement for a site to be detectable as a PWA in some browsers, and includes PWA-specific metadata like the name of the app, additional icons, display mode, etc.
The final piece is a service worker, which is where the “offline” part happens. Here’s what it looks like:
const secondsSinceEpoch = Math.round(Date.now() / 1000);
const PRECACHE = `precache-v${secondsSinceEpoch}`;
const RUNTIME = 'runtime';
// A list of local resources we always want to be cached.
const PRECACHE_URLS = [
'/',
'/index.html',
'/assets/main.css',
'/app.js',
'/sounds/house-fan-60.mp3',
'/assets/frame.gif',
'/assets/louvers.gif',
'/assets/blades.gif',
'/assets/toggle.gif',
'/assets/off.gif',
'/assets/on.gif'
];
// The install handler takes care of precaching the resources we always need.
self.addEventListener('install', event => {
event.waitUntil(
caches.open(PRECACHE)
.then(cache => cache.addAll(PRECACHE_URLS))
.then(self.skipWaiting())
);
});
// The activate handler takes care of cleaning up old caches.
self.addEventListener('activate', event => {
const currentCaches = [PRECACHE, RUNTIME];
event.waitUntil(
caches.keys().then(cacheNames => {
return cacheNames.filter(cacheName => !currentCaches.includes(cacheName));
}).then(cachesToDelete => {
return Promise.all(cachesToDelete.map(cacheToDelete => {
return caches.delete(cacheToDelete);
}));
}).then(() => self.clients.claim())
);
});
// The fetch handler serves responses for same-origin resources from a cache.
// If no response is found, it populates the runtime cache with the response
// from the network before returning it to the page.
self.addEventListener('fetch', event => {
// Skip cross-origin requests, like those for Google Analytics.
if (event.request.url.startsWith(self.location.origin)) {
event.respondWith(
caches.match(event.request).then(cachedResponse => {
if (cachedResponse) {
return cachedResponse;
}
return caches.open(RUNTIME).then(cache => {
return fetch(event.request).then(response => {
// Put a copy of the response in the runtime cache.
return cache.put(event.request, response.clone()).then(() => {
return response;
});
});
});
})
);
}
});
This is 99% boilerplate that I found… somewhere? The part worth mentioning is the PRECACHE_URLS
array. Every resource that you want to be available offline needs to be represented there.
Put all the pieces together, and you get a nice little phone experience. App icon, app-ish feel, and it works 100% offline, forever. Makes me want to build a little library of single purpose, offline PWAs.