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-pagesis 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
SoundRepositorypattern, 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
- Sample-accurate looping and crossfade control required for ambient playback cannot be achieved with the
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.
- Stable-segment detection: analyzes RMS moving variance and onset density with librosa to automatically pick a continuous segment with the fewest transient sounds
- Loop creation: applies an equal-power crossfade at the end of the segment so the tail connects seamlessly back to the head
- Mastering: high-pass filtering + EBU R128 loudness normalization (linear gain only — no compression or EQ, to preserve continuity at the loop boundary)
- 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:audiometa 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 deployautomatically - Testing: focused on the logic layer — DSP (constant sound pressure across fade curves), mix-URL serialization, and the content loader
