ChibihamChibiham
Otoshiori (音栞)
🎐

Otoshiori (音栞)

A web service that records and archives Japan's disappearing soundscapes — shopping arcades, railroad crossings slated for removal, wooden station buildings, festivals — together with first-person written accounts.

Production: otoshiori.chibiham.app

Concept

Collect, listen to, and preserve environmental sounds as "bookmarks of memory" (音栞, oto-shiori).

  • Sounds must be recordings of real places. No AI-based audio restoration or generation is used
  • Archives both "disappearing" sounds recorded by myself and "already lost" sounds from open sources such as Wikimedia Commons and Freesound
  • Each sound is accompanied by a first-person account from the recordist, making it a memory of a place rather than a mere sound-effect library

Architecture

  • Cloudflare Workers + OpenNext: A fully static (SSG) Next.js 15 app deployed to Workers via @opennextjs/cloudflare
  • Cloudflare R2: Audio delivery with a custom domain and CORS configured for Range requests
  • Cloudflare D1: Aggregates play counts and manages the submission ledger — serverless SQLite with zero operational cost
  • Terraform + Terraform Cloud: R2 buckets, custom domains, and D1 managed as IaC. The CD pipeline automates terraform apply → D1 migrations → deploy
  • Monorepo: apps/web (app) / packages/content (content layer) / tools (Python audio processing) / terraform (IaC), managed with pnpm workspaces

Technology Choices

  • Full Cloudflare stack
    • Audio delivery cost (2–4 MB per track) was the biggest concern, so the architecture is built around R2 and its free egress
    • All pages are SSG; the only dynamic parts are play counting and submission intake, which Workers + D1 handle without any servers to operate
  • @opennextjs/cloudflare
    • @cloudflare/next-on-pages is deprecated. Since every page is SSG, the read-only static-assets incremental cache is sufficient
  • Content-as-code (no database)
    • With a small catalog in Phase 1, content lives in MDX files validated by Zod schemas instead of a database
    • Content access is abstracted behind a SoundRepository pattern, so it can be swapped for a D1/Supabase implementation once UGC grows
  • Web Audio API (standard API, no libraries)
    • Sample-accurate looping and crossfade control required for ambient playback cannot be achieved with the <audio> element

Notable Implementations

Sample-accurate loop playback engine

Because <audio loop> introduces a gap at the loop point due to encoder delay, playback uses AudioBufferSourceNode with loopStart/loopEnd for sample-accurate looping.

The "long listening" mode additionally implements dual-layer playback, adapting ambience techniques from game audio:

  • Two layers are created from the same buffer, offset by half a cycle
  • They swap via equal-power crossfades on a 90-second period, eliminating the repetitiveness of a fixed loop
  • The equal-power fade curve is unit-tested to keep total sound pressure (sum of squares) constant during crossfades

Mixing and shareable URLs

A mixer lets users layer multiple sounds and noise beds (white/pink noise) with individual volumes. The mix configuration is serialized into the query string, so a mix can be shared and restored via URL alone.

/sounds?m=slug1:0.8,slug2:0.5&n=pink:0.2&master=0.8&loop=dual

Python audio preprocessing pipeline

A custom CLI turns raw recordings into distribution-ready audio.

  1. Stable-segment detection: analyzes RMS moving variance and onset density with librosa to automatically pick a continuous segment with the fewest transient sounds
  2. Loop creation: applies an equal-power crossfade at the end of the segment so the tail connects seamlessly back to the head
  3. Mastering: high-pass filtering + EBU R128 loudness normalization (linear gain only — no compression or EQ, to preserve continuity at the loop boundary)
  4. Encoding: Opus at 96 kbps, compressing to 2–4 MB (e.g. a 17-minute / 19 MB recording → 195 seconds / 2.1 MB)

Maximum track length is designed around decoded in-memory size on mobile (70–110 MB).

Build-time content validation

MDX frontmatter is validated with Zod schemas, so invalid metadata — loopEnd > duration, unknown prefectures or scene tags, missing license attribution for external sources — fails the build immediately.

Contribution flow

Visitors can submit their own recordings through a contribution form.

  • Bot protection via a honeypot field, plus file-format and size (95 MB limit) validation
  • Audio and metadata are stored in a private R2 staging bucket, with review status tracked in a D1 ledger
  • Accepted submissions go through the Python pipeline before being published to the production bucket

Other features

  • Map view: recording locations displayed on a map with MapLibre GL
  • Play counts: a per-session beacon fires after 30 seconds of playback and is aggregated in D1 via UPSERT (no personal data)
  • SEO: JSON-LD (WebSite / AudioObject / BreadcrumbList), dynamic OGP image generation, and og:audio meta tags

Development & Operations

  • CI: format / lint / typecheck / test (Vitest) / build / terraform validate on every PR
  • CD: pushes to main run terraform apply → D1 migrations → OpenNext build → wrangler deploy automatically
  • Testing: focused on the logic layer — DSP (constant sound pressure across fade curves), mix-URL serialization, and the content loader