Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone a8c677472ab5691741798a3d
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/lib/mixer.ts
// The sound mixer: several soundscapes at once, each with its own volume, and a sleep timer that
// fades everything out slowly. It keeps playing while the person moves around the app.
import { useSyncExternalStore } from "react";
import { audio, hold, release, setMedia, type Voice } from "./audio";
import { SOUNDS, soundName, startSound, type SoundId } from "./noise";
import { read, write } from "./store";
export type Levels = Partial<Record<SoundId, number>>;
export type Timer = { minutes: number; endsAt: number | null; left: number };
export type MixState = { levels: Levels; playing: boolean; timer: Timer | null };
export const PRESETS: { name: string; levels: Levels }[] = [
{ name: "Rainy night", levels: { rain: 0.7, fire: 0.35 } },
{ name: "Ocean sleep", levels: { ocean: 0.75, wind: 0.2 } },
{ name: "Deep focus", levels: { brown: 0.7 } },
{ name: "Cabin storm", levels: { "heavy-rain": 0.6, fire: 0.45, wind: 0.25 } },
{ name: "Forest stream", levels: { stream: 0.6, wind: 0.3 } },
{ name: "Bedroom fan", levels: { fan: 0.7 } },
];
/** How long the fade at the end of a sleep timer lasts: up to five minutes. */
export const fadeSeconds = (minutes: number) => Math.min(300, Math.max(10, (minutes * 60) / 3));
let state: MixState = { levels: read<Levels>("still.mix", {}), playing: false, timer: null };
const subs = new Set<() => void>();
const playing = new Map<SoundId, { gain: GainNode; voice: Voice }>();
let bus: GainNode | null = null;
let timerId: number | undefined;
let started = false;
function emit(patch: Partial<MixState>) {
state = { ...state, ...patch };
if (patch.levels) write("still.mix", state.levels);
subs.forEach((f) => f());
}
export const getMix = () => state;
export function useMix(): MixState {
return useSyncExternalStore((f) => { subs.add(f); return () => subs.delete(f); }, getMix);
}
export const mixName = (levels: Levels) => {
const names = SOUNDS.filter((s) => (levels[s.id] ?? 0) > 0).map((s) => soundName(s.id));
return names.length ? names.length > 2 ? `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}` : names.join(" and ") : "Nothing yet";
};
function mixBus(): GainNode {
if (!bus) {
const { ctx, buses } = audio();
bus = ctx.createGain();
bus.connect(buses.sounds);
}
return bus;
}
/** Starts, stops and sets the volume of each sound to match the state, with soft fades. */
function apply() {
const { ctx } = audio();
const now = ctx.currentTime;
for (const s of SOUNDS) {
const level = state.playing ? state.levels[s.id] ?? 0 : 0;
const live = playing.get(s.id);
if (level > 0) {
if (live) {
live.gain.gain.cancelScheduledValues(now);
live.gain.gain.setTargetAtTime(level * level, now, 0.15);
} else {
const gain = ctx.createGain();
gain.gain.setValueAtTime(0, now);
gain.gain.setTargetAtTime(level * level, now, 0.6);
gain.connect(mixBus());
playing.set(s.id, { gain, voice: startSound(ctx, s.id, gain) });
}
} else if (live) {
playing.delete(s.id);
live.gain.gain.cancelScheduledValues(now);
live.gain.gain.setTargetAtTime(0, now, 0.4);
window.setTimeout(() => { live.voice.stop(); live.gain.disconnect(); }, 2500);
}
}
setMedia("mix", started && hasSound()
? { title: mixName(state.levels), album: "Sounds", playing: state.playing, onPlay: play, onPause: pause, onStop: pause }
: null);
}
const hasSound = () => Object.values(state.levels).some((v) => (v ?? 0) > 0);
export function play() {
if (!hasSound()) return;
started = true;
hold("mix");
emit({ playing: true });
busTo(1);
apply();
arm();
}
export function pause() {
disarm(true);
emit({ playing: false });
apply();
release("mix");
}
export function toggleSound(id: SoundId) {
const levels = { ...state.levels };
if ((levels[id] ?? 0) > 0) delete levels[id];
else levels[id] = 0.6;
emit({ levels });
if (!hasSound()) return pause();
if (state.playing) apply();
else play();
}
export function setLevel(id: SoundId, v: number) {
emit({ levels: { ...state.levels, [id]: v } });
if (state.playing) apply();
}
/** Replaces the whole mix (a preset, a saved mix or a link). */
export function setMix(levels: Levels, start: boolean) {
emit({ levels: { ...levels } });
if (start) play();
else if (state.playing) apply();
}
/** Sets the sleep timer, or turns it off with null. The last part fades slowly to silence. */
export function setTimer(minutes: number | null) {
disarm(false);
emit({ timer: minutes ? { minutes, endsAt: null, left: minutes * 60 } : null });
if (state.playing) { busTo(1); arm(); }
}
function arm() {
const t = state.timer;
if (!t || t.endsAt) return;
const { ctx } = audio();
const g = mixBus().gain;
const now = ctx.currentTime;
const fade = Math.min(fadeSeconds(t.minutes), t.left);
g.cancelScheduledValues(now);
g.setValueAtTime(1, now);
g.setValueAtTime(1, now + t.left - fade);
g.linearRampToValueAtTime(0, now + t.left);
emit({ timer: { ...t, endsAt: Date.now() + t.left * 1000 } });
timerId = window.setTimeout(finish, t.left * 1000);
}
/** Sets the volume of the whole mix now, or holds it where a fade has got to (null). */
function busTo(value: number | null) {
if (!bus) return;
const now = audio().ctx.currentTime;
const v = value ?? bus.gain.value;
bus.gain.cancelScheduledValues(now);
bus.gain.setValueAtTime(v, now);
}
/** Stops the timer clock and holds the volume where it is; with `keep`, the time left is kept for when playing resumes. */
function disarm(keep: boolean) {
window.clearTimeout(timerId);
busTo(null);
const t = state.timer;
if (keep && t?.endsAt) emit({ timer: { ...t, endsAt: null, left: Math.max(0, (t.endsAt - Date.now()) / 1000) } });
}
/** The timer ran out: the mix is already silent, so stop each sound without bringing the volume back. */
function finish() {
window.clearTimeout(timerId);
emit({ timer: null, playing: false });
apply();
release("mix");
}
// Background tabs may run timers late; check again whenever the page comes back.
document.addEventListener("visibilitychange", () => {
const t = state.timer;
if (t?.endsAt && Date.now() >= t.endsAt) finish();
});
/** Mixes the person saved, kept on this device. */
export type SavedMix = { name: string; levels: Levels };
export const MIXES_KEY = "still.mixes";
/** Reads a mix from a link: ?mix=rain-60,fire-30 (volume in percent, 60 when left out). */
export function parseMix(value: string | null): Levels | null {
if (!value) return null;
const levels: Levels = {};
for (const part of value.split(",")) {
const m = part.trim().match(/^([a-z-]+?)(?:-(\d{1,3}))?$/);
const id = m && SOUNDS.find((s) => s.id === m[1])?.id;
if (id) levels[id] = Math.min(100, Math.max(5, Number(m[2] ?? 60))) / 100;
}
return Object.keys(levels).length ? levels : null;
}
export const mixParam = (levels: Levels) =>
SOUNDS.filter((s) => (levels[s.id] ?? 0) > 0).map((s) => `${s.id}-${Math.round((levels[s.id] ?? 0) * 100)}`).join(",");
Change history
Link to OpenApps pages and add a contribution strip
Bind the agent address in the policy
Add favicon.ico so browsers that ask for it stop getting a 404
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync