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/audio.ts
// One audio engine for the whole app: Web Audio for sounds, bells and breathing tones, all made
// on the device. It also owns the lock screen controls (Media Session) and the screen wake lock.
import type { Settings } from "./store";

type Buses = { sounds: GainNode; bells: GainNode; tones: GainNode };

let ctx: AudioContext | null = null;
let buses: Buses | null = null;
let element: HTMLAudioElement | null = null;
const holds = new Set<string>();
let idleTimer: number | undefined;

/**
 * The audio context, created and resumed on a tap. Chrome and Firefox play through an <audio>
 * element fed by the graph, which is what gives lock screen controls and keeps sound going with the
 * screen off on Android. Safari plays the graph directly, as a "playback" audio session, so sound
 * is not silenced by the ring switch.
 */
export function audio(): { ctx: AudioContext; buses: Buses } {
  if (!ctx || !buses) {
    const AC = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
    const c = new AC({ latencyHint: "playback" });
    const master = c.createGain();
    const session = (navigator as Navigator & { audioSession?: { type: string } }).audioSession;
    if (session) {
      try { session.type = "playback"; } catch { /* older Safari */ }
      master.connect(c.destination);
    } else if (typeof c.createMediaStreamDestination === "function") {
      const dest = c.createMediaStreamDestination();
      master.connect(dest);
      element = new Audio();
      element.srcObject = dest.stream;
      element.play().catch(() => {
        master.disconnect();
        master.connect(c.destination);
        element = null;
      });
    } else {
      master.connect(c.destination);
    }
    const bus = (level: number) => {
      const g = c.createGain();
      g.gain.value = level;
      g.connect(master);
      return g;
    };
    // Many sounds at full volume would clip; a gentle limiter keeps the mix clean.
    const limiter = c.createDynamicsCompressor();
    limiter.threshold.value = -10;
    limiter.knee.value = 8;
    limiter.ratio.value = 6;
    limiter.connect(master);
    const sounds = c.createGain();
    sounds.gain.value = 0.7; // offsets the limiter's built-in makeup gain
    sounds.connect(limiter);
    ctx = c;
    buses = { sounds, bells: bus(0.55), tones: bus(0.5) };
  }
  if (ctx.state !== "running") void ctx.resume();
  if (element?.paused) void element.play().catch(() => null);
  return { ctx, buses };
}

/** Marks something as playing, so the engine stays awake. */
export function hold(name: string) {
  holds.add(name);
  window.clearTimeout(idleTimer);
  audio();
}

/** Something stopped. When nothing plays for a while, the engine sleeps to save battery. */
export function release(name: string) {
  holds.delete(name);
  window.clearTimeout(idleTimer);
  if (holds.size) return;
  idleTimer = window.setTimeout(() => {
    if (holds.size || !ctx) return;
    void ctx.suspend();
    element?.pause();
  }, 12000);
}

// Lock screen controls: the latest thing that started owns them.
type Media = { title: string; album?: string; playing: boolean; onPlay: () => void; onPause: () => void; onStop: () => void };
const media = new Map<string, Media>();
let mediaOrder: string[] = [];

export function setMedia(owner: string, m: Media | null) {
  if (m) {
    media.set(owner, m);
    mediaOrder = [...mediaOrder.filter((o) => o !== owner), owner];
  } else {
    media.delete(owner);
    mediaOrder = mediaOrder.filter((o) => o !== owner);
  }
  applyMedia();
}

function applyMedia() {
  if (!("mediaSession" in navigator)) return;
  const ms = navigator.mediaSession;
  const top = mediaOrder.length ? media.get(mediaOrder[mediaOrder.length - 1]) : undefined;
  const set = (action: MediaSessionAction, fn: (() => void) | null) => { try { ms.setActionHandler(action, fn); } catch { /* unsupported action */ } };
  if (!top) {
    ms.metadata = null;
    ms.playbackState = "none";
    for (const a of ["play", "pause", "stop"] as MediaSessionAction[]) set(a, null);
    return;
  }
  ms.metadata = new MediaMetadata({
    title: top.title,
    artist: "Still",
    album: top.album || "Free meditation and sleep",
    artwork: [
      { src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
      { src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
    ],
  });
  ms.playbackState = top.playing ? "playing" : "paused";
  set("play", top.onPlay);
  set("pause", top.onPause);
  set("stop", top.onStop);
}

// Keeps the screen on during a session where the browser allows it. Browsers drop the lock when
// the page is hidden, so it is asked for again when the page comes back.
type Sentinel = { release(): Promise<void>; addEventListener(t: "release", f: () => void): void };
let lock: Sentinel | null = null;
let wanted = 0;
async function acquire() {
  const wl = (navigator as Navigator & { wakeLock?: { request(t: "screen"): Promise<Sentinel> } }).wakeLock;
  if (!wl || lock || !wanted || document.visibilityState !== "visible") return;
  lock = await wl.request("screen").catch(() => null);
  lock?.addEventListener("release", () => { lock = null; });
}
export function keepAwake(on: boolean) {
  wanted = Math.max(0, wanted + (on ? 1 : -1));
  if (wanted) void acquire();
  else if (lock) { void lock.release().catch(() => null); lock = null; }
}
document.addEventListener("visibilitychange", () => { void acquire(); });

/** A playing node that can be stopped early, with a short fade so it never clicks. */
export type Voice = { stop: () => void };

const BELLS: Record<Settings["bell"], { f: number; partials: [ratio: number, gain: number, decay: number][] }> = {
  // A singing bowl: low, long, with slow beating between near partials.
  bowl: { f: 196, partials: [[1, 1, 11], [1.004, 0.6, 10], [2.71, 0.45, 6], [2.716, 0.25, 5.5], [5.15, 0.18, 3.2], [8.3, 0.07, 1.8]] },
  // A small temple bell: brighter, with a hum below the strike note.
  bell: { f: 330, partials: [[0.5, 0.35, 9], [1, 1, 6], [1.19, 0.4, 4.5], [1.5, 0.3, 3.5], [2, 0.45, 3], [2.52, 0.15, 2], [3.01, 0.1, 1.5]] },
  // A light chime, like a tuned bar.
  chime: { f: 784, partials: [[1, 1, 4.5], [2.756, 0.32, 2.2], [5.404, 0.12, 1.1]] },
};

/** Rings a bell at an audio time (default now). Returns a handle to silence it. */
export function bell(kind: Settings["bell"], at?: number, level = 1): Voice {
  const { ctx: c, buses: b } = audio();
  const t = Math.max(at ?? c.currentTime, c.currentTime) + 0.02;
  const out = c.createGain();
  out.gain.value = level;
  out.connect(b.bells);
  const spec = BELLS[kind];
  const nodes: AudioScheduledSourceNode[] = [];
  for (const [ratio, gain, decay] of spec.partials) {
    const o = c.createOscillator();
    o.frequency.value = spec.f * ratio;
    const g = c.createGain();
    g.gain.setValueAtTime(0, t);
    g.gain.linearRampToValueAtTime(gain * 0.22, t + 0.012);
    g.gain.exponentialRampToValueAtTime(0.0001, t + decay);
    o.connect(g).connect(out);
    o.start(t);
    o.stop(t + decay + 0.1);
    nodes.push(o);
  }
  // The soft strike of a padded mallet.
  const len = Math.floor(c.sampleRate * 0.03);
  const buf = c.createBuffer(1, len, c.sampleRate);
  const d = buf.getChannelData(0);
  for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.exp(-i / (len / 5));
  const strike = c.createBufferSource();
  strike.buffer = buf;
  const lp = c.createBiquadFilter();
  lp.type = "lowpass";
  lp.frequency.value = spec.f * 6;
  const sg = c.createGain();
  sg.gain.value = 0.08;
  strike.connect(lp).connect(sg).connect(out);
  strike.start(t);
  nodes.push(strike);
  return {
    stop() {
      const now = c.currentTime;
      out.gain.cancelScheduledValues(now);
      out.gain.setTargetAtTime(0, now, 0.05);
      for (const n of nodes) { try { n.stop(now + 0.3); } catch { /* already stopped */ } }
    },
  };
}

/** A soft breathing tone: rising while breathing in, falling while breathing out. */
export function breathTone(dir: "in" | "out", at: number, seconds: number): Voice {
  const { ctx: c, buses: b } = audio();
  const low = 174.6;
  const high = 233.1;
  const t = Math.max(at, c.currentTime);
  const end = t + Math.max(0.6, seconds - 0.08);
  const g = c.createGain();
  g.gain.setValueAtTime(0, t);
  g.gain.linearRampToValueAtTime(0.16, t + Math.min(1.2, seconds * 0.35));
  g.gain.setValueAtTime(0.16, Math.max(t + 0.3, end - Math.min(1.2, seconds * 0.35)));
  g.gain.linearRampToValueAtTime(0, end);
  g.connect(b.tones);
  const nodes: OscillatorNode[] = [];
  for (const [mult, level] of [[1, 1], [2, 0.12], [1.5, 0.05]] as const) {
    const o = c.createOscillator();
    o.type = "sine";
    const og = c.createGain();
    og.gain.value = level;
    o.frequency.setValueAtTime((dir === "in" ? low : high) * mult, t);
    o.frequency.exponentialRampToValueAtTime((dir === "in" ? high : low) * mult, end);
    o.connect(og).connect(g);
    o.start(t);
    o.stop(end + 0.05);
    nodes.push(o);
  }
  return {
    stop() {
      const now = c.currentTime;
      g.gain.cancelScheduledValues(now);
      g.gain.setTargetAtTime(0, now, 0.05);
      for (const n of nodes) { try { n.stop(now + 0.3); } catch { /* already stopped */ } }
    },
  };
}

/** Short phone vibrations (Android; iPhones do not let web pages vibrate). */
export function buzz(pattern: number | number[]) {
  if ("vibrate" in navigator) { try { navigator.vibrate(pattern); } catch { /* not allowed */ } }
}
export const canVibrate = typeof navigator !== "undefined" && "vibrate" in navigator;

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