still-free.openapps.xyz

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.
Owned app · rev 0fa0e84d537b
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.
← All files
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
0fa0e84d53 · 19 hours ago
Bind the agent address in the policy
6f3baa1c72 · 1 day ago
Add favicon.ico so browsers that ask for it stop getting a 404
1a004fe0e3 · 1 day ago
Bounded source sync
afe642fe50 · 2 days ago
Bounded source sync
2c581f55ea · 2 days ago
Bounded source sync
375934c28b · 2 days ago
Bounded source sync
74ff37d51a · 2 days ago