Audio & music

Two dedicated modules give widgets live sound and playback state — without shelling out to binaries the user may not have installed.
ModuleWhat it isImport
appinapp-audioSystem-audio frames for visualizersimport { onAudioFrame } from "appinapp-audio"
appinapp-musicNow-playing info + playback controlsimport { onNowPlaying, getNowPlaying, onPlayers, getPlayers, play, pause, togglePlayPause, next, previous, seek } from "appinapp-music"
Both are named imports only and go through the same sandbox allowlist as appinapp-react / appinapp-icons. Anything else throws Import not allowed: <name>.
Never shell out to nowplaying-cli (or similar tools) via run() for track data. Now-playing data comes from appinapp-music only — widgets must not depend on binaries the user may not have installed.

appinapp-audio — visualizer frames

onAudioFrame(cb) subscribes to raw system-audio frames at ~60 Hz. Use it for bars, waveforms, ambient glow — anything that reacts to what is playing.
import { onAudioFrame } from "appinapp-audio";

Frame shape

cb receives { spectrum, waveform, rms } on every tick:
FieldTypeDescription
spectrumnumber[64]Frequency magnitudes, each ~0–1. Use directly as bar heights.
waveformnumber[512]Time-domain samples, each ~-1–1. Draw as an oscilloscope line.
rmsnumberOverall loudness (~0–1). Use for glow / scale pulsing.

Lifecycle

  • The first onAudioFrame call starts capture (Core Audio process tap — no screen-recording indicator). The OS may ask once for audio access.
  • The callback never fires while permission is denied or nothing is playing — render an idle state, not an error.
  • When the last subscriber unsubscribes, capture stops — zero CPU when no visualizer is mounted.
  • Returns an unsubscribe function — always return it from useEffect.
import { useEffect, useRef } from "appinapp-react";
import { onAudioFrame } from "appinapp-audio";

const AudioBars = () => {
  const canvasRef = useRef(null);
  const frameRef = useRef(null);

  useEffect(() => {
    const unsubscribe = onAudioFrame((frame) => {
      frameRef.current = frame;
    });
    return () => unsubscribe();
  }, []);

  return <canvas ref={canvasRef} width={300} height={120} />;
};

export default AudioBars;

Performance rule

Frames arrive at ~60 Hz. Do not setState on every frame — 60 re-renders per second kills performance. Store the latest frame in a ref and draw from requestAnimationFrame:
useEffect(() => {
  const unsubscribe = onAudioFrame((frame) => {
    frameRef.current = frame; // no setState here
  });

  let raf = 0;
  const draw = () => {
    raf = requestAnimationFrame(draw);
    const canvas = canvasRef.current;
    const frame = frameRef.current;
    if (!canvas || !frame) return;
    const ctx = canvas.getContext("2d");
    const { width, height } = canvas;
    ctx.clearRect(0, 0, width, height);
    const bw = width / frame.spectrum.length;
    frame.spectrum.forEach((v, i) => {
      const h = Math.max(2, v * height);
      ctx.fillStyle = "#4ade80";
      ctx.fillRect(i * bw + 1, height - h, bw - 2, h);
    });
  };
  raf = requestAnimationFrame(draw);

  return () => {
    cancelAnimationFrame(raf);
    unsubscribe();
  };
}, []);
rms is the cheapest way to add life to a static widget — pulse opacity, blur, or scale from it even if you do not draw a full spectrum.

appinapp-music — now playing + controls

import {
  onNowPlaying, getNowPlaying,
  onPlayers, getPlayers,
  play, pause, togglePlayPause,
  next, previous, seek,
} from "appinapp-music";
FunctionWhat it does
onNowPlaying(cb)Subscribe to 1 Hz updates while something plays (plus one { playing: false, … } when playback stops). Returns unsubscribe.
getNowPlaying()One-shot snapshot of the same payload (artwork included). Prefer onNowPlaying for continuous UI.
onPlayers(cb) / getPlayers()Per-app players list (multiple apps at once). Subscribe / one-shot snapshot.
play(target?), pause(target?), togglePlayPause(target?), next(target?), previous(target?)Playback controls. Work without subscribing.
seek(seconds)Jump to a position in seconds (non-negative number). The new position arrives via onNowPlaying on the next tick.

Now-playing payload

onNowPlaying / getNowPlaying deliver:
{
  playing,            // boolean
  title, artist, album, genre,   // strings | null (null when the player omits them)
  duration,           // seconds | null
  elapsedTime,        // seconds, extrapolated to now
  playbackRate, trackNumber, totalTrackCount,
  uniqueIdentifier, bundleIdentifier, processIdentifier,
  artwork             // { mimeType, dataUrl } | null
}
Notes:
  • All metadata fields are nullable — the player decides what it provides.
  • elapsedTime is extrapolated to now, so a progress bar stays smooth between 1 Hz ticks.
  • artwork arrives on the first emit and on track change only — cache the last non-null artwork per app instead of clearing the image on every update.
import { useEffect, useState } from "appinapp-react";
import { togglePlayPause, next, onNowPlaying } from "appinapp-music";

const MusicBar = () => {
  const [track, setTrack] = useState(null);
  const [art, setArt] = useState(null);

  useEffect(() => {
    return onNowPlaying((info) => {
      setTrack(info);
      if (info.artwork) setArt(info.artwork); // cache: only sent on track change
    });
  }, []);

  if (!track?.playing) return <div>Nothing playing</div>;

  return (
    <div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
      {art && <img src={art.dataUrl} style={{ width: 40, height: 40 }} />}
      <span>{track.title} — {track.artist}</span>
      <button onClick={() => togglePlayPause()}>⏯</button>
      <button onClick={() => next()}>⏭</button>
    </div>
  );
};

export default MusicBar;

Players list — multiple apps

The system slot shows one app, but several may hold media sessions (paused video in a tab + background Spotify). onPlayers(cb) fires whenever the list meaningfully changes (app / track / state flips) and returns an unsubscribe; getPlayers() is the one-shot snapshot.
Rows arrive slot-owner-first:
{
  bundleIdentifier, processIdentifier,
  appName, icon, title, artist, album, artwork,
  playing,  // live-verified while slot-owned or backed by an audio tap
  live,     // false = remembered row, may be stale (needs audio permission)
  active    // true = slot owner — the ONLY row controls act on
}
  • active marks the slot owner. Enable buttons only on the active row — controls never reach background rows (see below).
  • playing is live-verified while the app owns the slot or a per-process audio tap backs it (this needs system-audio permission — granted via any audio widget). Otherwise the row is remembered with live: false and may be stale.
  • artwork / icon arrive on track change only — cache per app like the single-track artwork.
import { useEffect, useState } from "appinapp-react";
import { onPlayers } from "appinapp-music";

const Players = () => {
  const [rows, setRows] = useState([]);
  useEffect(() => onPlayers(setRows), []);

  return (
    <div>
      {rows.map((r) => (
        <div key={r.bundleIdentifier || r.processIdentifier}>
          {r.appName}: {r.title} {r.playing ? "▶" : "⏸"}
          {r.live ? "" : " (remembered)"}
        </div>
      ))}
    </div>
  );
};

export default Players;

Playback controls — slot-only delivery

play(target?), pause(target?), togglePlayPause(target?), next(target?), previous(target?) — target is a bundle id string (e.g. "com.spotify.client") or a pid number. Each resolves to { ok, reason, appliedTo }:
  • The target owns the slot → sent directly ({ ok: true, reason: "sent" }).
  • A known background player with AppleScript support (Spotify and Music.app only) → fixed osascript (sent).
  • Anything else is refused without sending — a command can never silently control a different app than the one pressed.
reasonMeaning
"sent"Command delivered
"not_controllable"Known player, but does not own the slot
"unknown_target"No such player seen
"no_player"Slot unreadable
"automation_denied" / "automation_timeout"System blocked the script
Omitted target keeps the historical blind-send-to-slot behaviour. There is no way to move slot ownership to a background app, and Chrome / Safari / Telegram expose no control surface at all.
The first scripted command pops a system Automation prompt ("would like to control …"). Until approved, background Spotify / Music control fails closed.

Audio + music together

The two modules compose naturally — visualizer from appinapp-audio, identity + transport from appinapp-music. They subscribe independently, so mount each only while its UI is visible.
import { useEffect, useRef, useState } from "appinapp-react";
import { onAudioFrame } from "appinapp-audio";
import { onNowPlaying, togglePlayPause } from "appinapp-music";

const NowPlayingVisualizer = () => {
  const [track, setTrack] = useState(null);
  const frameRef = useRef(null);

  useEffect(() => onNowPlaying(setTrack), []);
  useEffect(() => onAudioFrame((f) => { frameRef.current = f; }), []);

  if (!track?.playing) return <div>Nothing playing</div>;
  return (
    <div>
      <div>{track.title} — {track.artist}</div>
      <button onClick={() => togglePlayPause()}>⏯</button>
      {/* draw frameRef.current.spectrum to canvas via rAF */}
    </div>
  );
};

export default NowPlayingVisualizer;

Checklist

  • Named imports only — import { onAudioFrame } from "appinapp-audio", never a default import or window.*.
  • Cleanup — always return the unsubscribe from useEffect for onAudioFrame, onNowPlaying, and onPlayers.
  • No 60 Hz setState — stash audio frames in a ref, draw with requestAnimationFrame.
  • Cache artwork — artwork / icon are sent on track change only; keep the last non-null value per app.
  • Gate controls on active — only the slot-owner row is controllable; everything else stays disabled.
  • Handle silence — onAudioFrame stays quiet when denied / idle; onNowPlaying emits one playing: false then stays quiet. Render idle UI, not errors.
  • No nowplaying-cli — all playback state comes from appinapp-music.