Generated: 2026-04-24
apg-web is a browser-based Audio Program Generator — a single-page app that converts a structured text script into a fully mixed audio file. The user writes (or uploads) a program in a simple phrase; pause_seconds format, picks a TTS engine and voice, optionally layers in background music, and downloads a finished MP3 or WAV.
Target user: Someone creating guided audio experiences: meditation sessions, yoga flows, qi gong sequences, stretch routines, language drills, or any guided practice where a voice speaks cues with timed pauses between them. The tool is personal/hobbyist in nature — there is no server, no accounts, no billing UI. The user is expected to supply their own API key.
Problem solved: Producing guided audio programs normally requires a DAW or scripted toolchain. This app moves the whole workflow into the browser with zero installation, handles API calls and audio mixing client-side, and saves money by caching generated speech so re-runs don't repeat API calls.
Type: Single-page application (SPA) — one index.html, no router, no build step, no bundler. Served statically; works from file:// or any static host.
Module structure:
index.html — Shell, all UI markup, two inline scripts (offline detection, version fetch)
scripts/
main.js — Entry point; instantiates AppController on DOMContentLoaded
controllers/
AppController.js — God-object controller (~2275 lines); owns all UI wiring + workflow
services/
TTSService.js — Facade over TTS adapters; manages engine selection + Web Speech API
TTSEngineAdapter.js — Abstract base class for TTS adapters
GoogleCloudTTSAdapter.js — Google Cloud TTS adapter
OpenAITTSAdapter.js — OpenAI TTS adapter (tts-1, tts-1-hd, gpt-4o-mini-tts)
GTTSAdapter.js — Google Translate TTS (free, unauthenticated)
AudioService.js — Web Audio API: decode, mix, fade, WAV/MP3 export, EQ
TTSCacheService.js — IndexedDB cache for TTS audio blobs (100 MB LRU)
ProjectCacheService.js — IndexedDB store for full project snapshots (last 10)
TextEditorService.js — Editor stats, syntax validation, localStorage auto-save
FileService.js — File upload reading/validation
SampleAudioService.js — Discovers and loads bundled MP3 samples from /samples/
utils/
parser.js — Parses the `phrase; seconds` text format into an object array
styles/
custom.css — Supplement to Pico.css; editor + mobile UX styles
samples/ — 5 bundled background MP3 files
tests/unit/ — Jest unit tests (~3930 lines, 9 test files)
Key relationships:
AppControllerowns all services as instance properties.- On submit, it calls
parseTextFile()→ loops phrases → callsgenerateOrGetCachedSpeech()(which consultsTTSCacheService, then delegates toTTSService/adapter) → accumulatesAudioBufferobjects → callsAudioServiceto concatenate/mix/fade → storescurrentAudioBuffer→ shows output UI. - Download triggers
AudioService.audioBufferToMP3()(lamejs) oraudioBufferToWav(), then usesshowSaveFilePicker/ blob-URL fallback.
| Layer | Choice | Notes |
|---|---|---|
| Language | Vanilla ES2022 JavaScript | ESM modules (type: module), no TypeScript |
| CSS framework | Pico.css v2 (CDN) | Minimal semantic CSS; dark/light via data-theme |
| MP3 encoder | lamejs 1.2.1 (CDN) | Pure-JS LAME wrapper; loaded as global, not ESM |
| Audio APIs | Web Audio API, Web Speech API | Both browser-native |
| Storage | localStorage + IndexedDB |
Keys/settings in localStorage; audio blobs in IndexedDB |
| TTS APIs | OpenAI /v1/audio/speech, Google Cloud texttospeech.googleapis.com, Google Translate TTS (free), Web Speech API |
4 engines total |
| Testing | Jest 29 + jsdom | --experimental-vm-modules for ESM |
| Linting/formatting | ESLint 9 flat config + Prettier 3 | Enforced on commit via husky + lint-staged |
| Build | None | No bundler; npx serve . for local dev |
| Export | File System Access API (showSaveFilePicker) with anchor-tag fallback |
Notable choices:
- No build step is a deliberate simplicity tradeoff. Works fine for this scale but means no tree-shaking, no TypeScript inference, no source maps.
- lamejs loaded as CDN global (not
import) because it predates ESM. This creates an offline dependency for MP3 export (detected via offline banner). - All data stays in the browser — no server component at all. API keys go only to OpenAI/Google endpoints directly.
-
Text editor for programs — in-browser textarea with auto-save to
localStorage(2s debounce), live stats (lines/chars/words), and real-time syntax validation with error/warning display. -
File upload mode — alternative to the editor; accepts
.txtfiles up to 10 MB. -
TTS engine selection — four engines available:
- OpenAI TTS (tts-1, tts-1-hd, gpt-4o-mini-tts) — 13 voices, speed control, optional voice instructions for gpt-4o-mini-tts
- Google Cloud TTS — Neural2 voices, speaking rate / pitch / volume gain, dynamic voice discovery after API key entry
- Google Translate TTS (gTTS) — free, no key required, regional accent selection
- Web Speech API — browser-native playback only (no export or mixing)
-
API key management — test and save buttons for OpenAI/Google Cloud keys, stored in
localStorage, unsaved-change detection on the Save button. -
OpenAI voice preview — test any voice/model/instructions combo before generating, with caching to avoid repeat API hits.
-
Background audio mixing — upload an audio file or choose one of 5 built-in samples; configurable attenuation (dB), fade-in, and fade-out. Background loops to match speech duration. Disabled when Web Speech API is selected.
-
TTS caching — IndexedDB-backed; each phrase is keyed by
engine:text:options-hash. Cache hits are logged; LRU pruning keeps it under 100 MB. Clear-cache button available. -
Generation pipeline — progress bar + cancellation button while generating phrase-by-phrase; status text per phrase.
-
Audio playback —
<audio>element with native browser controls. -
3-band EQ — Web Audio API lowshelf/peaking/highshelf chain on the audio player; adjustable in real time, resettable.
-
Export — WAV (instant) or MP3 (in-browser lamejs encoding at 128/192/256/320 kbps) with progress bar. Uses
showSaveFilePickerwhere supported, blob URL fallback elsewhere. -
Descriptive filenames — downloaded files named
audio-program_YYYYMMDD-HHmmss_<desc>_<voice>_<bg>.<ext>. -
Project history — up to 10 recent projects stored in IndexedDB, shown in collapsible "Recent Projects" section; one-click Restore repopulates all settings and editor content.
-
Theme switcher — Light / Dark / Auto (system) / Auto (Sun-based, using NOAA solar calculation with geolocation).
-
Offline detection — fixed banner if Pico CSS or lamejs didn't load; live
online/offlineevent handling. -
Mobile UX — 44-48 px touch targets, responsive editor, collapsible sections.
User input
├─ Text editor textarea ─┐
└─ .txt file upload ├──► parseTextFile() → [{phrase, duration}, ...]
│
▼
Loop over phrases
│
▼
TTSCacheService.get(text, engine, options)
│
HIT ─────┘ MISS
│ │
│ TTSService.generatePhrase()
│ │
│ adapter.generateSpeech() → fetch(TTS API) → Blob
│ │
└───────────────┘
│
▼
speechBlob.arrayBuffer()
│
▼
AudioService.decodeAudioData() → AudioBuffer (speech)
+
AudioService.createSilence(phrase.duration) → AudioBuffer (pause)
│
▼
AudioService.concatenateBuffers([...]) → finalBuffer
│
(if background)
│
FileService.readAudioFile() → ArrayBuffer
AudioService.decodeAudioData() → AudioBuffer (background)
AudioService.mixBuffers(final, bg, {attenuation}) → mixed
AudioService.applyFades(mixed, {fadeIn, fadeOut}) → finalBuffer
│
▼
this.currentAudioBuffer = finalBuffer
this.currentAudioBlob = audioBufferToWav(finalBuffer)
│
▼
audio player src = blob URL
│
(on Download click)
│
AudioService.audioBufferToMP3(currentAudioBuffer, bitrate)
or audioBufferToWav(currentAudioBuffer)
│
▼
showSaveFilePicker / anchor click → file on disk
Side effects:
TTSCacheService.set(text, engine, options, blob) ← after each API call
ProjectCacheService.saveProject({...}) ← after successful generation
localStorage['apg_editor_content'] ← debounced on keypress
localStorage['openai-tts-api-key'] ← on save
localStorage['google-cloud-tts-api-key'] ← on save
localStorage['theme'] ← on theme change
Layout: Single-column, max-width 800px. Four collapsible <details> sections (Recent Projects, Create Phrase File, Configure TTS, Configure Background Audio) plus a permanently-open Generate/Preview/Save section. Sections collapse to reduce scroll on smaller screens.
Framework: Pico.css provides clean semantic defaults (form labels, selects, progress bars, <article> cards, <details> styling). Custom CSS is minimal — mostly the editor styling, validation indicators, and mobile overrides.
Progressive disclosure: All "advanced" settings are inside nested <details> elements (Editor Settings, Advanced TTS Settings, Equalizer). First-time users see a simple path: type → pick voice → generate.
Context-awareness:
- Background mixing UI is disabled/hidden when Web Speech API is selected.
- MP3 bitrate selector hidden when WAV format is chosen.
gpt-4o-mini-tts-only voices auto-upgrade the model selector (and downgrade when switching back).- Voice instructions textarea appears only for
gpt-4o-mini-tts.
Feedback patterns:
- Buttons temporarily show
✓ Saved,✅ Valid,🔊 Playing...,❌ Invalid key, etc., then revert after 2-3s. - Save button shows unsaved state (
Save *, contrast color) vs. saved state (Saved ✓, secondary). - Progress bar + cancellable generation.
- Auto-save indicator in editor stats bar.
Mobile: 44/48 px minimum touch targets enforced via CSS; editor max-height 400px on small screens; toolbar buttons go full-width.
Finished and working:
- Complete end-to-end workflow for all three API-based engines (OpenAI, Google Cloud, gTTS)
- Web Speech API playback mode
- TTS caching + project history (both full-featured)
- MP3/WAV export with EQ, background mixing, fades
- Voice preview with caching
- Recent projects: save, restore, delete, prune
- Descriptive filenames, theme switcher (including solar-based mode)
- Unit test suite (~3930 lines, 9 files)
In-progress / partially implemented:
TextEditorService.applySyntaxHighlighting()exists and produces HTML but is never called fromAppController. The editor is a plain<textarea>, not acontenteditableoverlay, so the highlighted HTML has nowhere to go. The CSS classes (.syntax-text,.syntax-duration,.syntax-separator) are defined but unused in the rendered UI. This is dead code from a planned highlighting feature that wasn't completed.TextEditorService.generateLineNumbers()is similarly implemented but never used — there are no line number gutters in the current editor.GTTSAdapterexists and the engine selector has it in the HTML source asvalue="gtts", but it is not listed in the<select>options the user sees (the gTTS option is absent fromtts-engineinindex.html). The adapter itself hits a CORS-restricted Google endpoint (translate.google.com/translate_tts) which would fail from a browser anyway. This adapter is vestigial.ProjectCacheService.listProjects()returns metadata only (excludes blobs for listing), butttsOptions,backgroundSettings, andprogramDescriptionare only returned ingetProject(). The project card display inloadRecentProjects()callslistProjects()and tries to readproject.programDescription,project.ttsOptions, andproject.backgroundSettings— these areundefinedfrom the list call. The card rendering silently falls through to defaults/fallbacks. This is a subtle data-shape bug that causes voice/background info to show incorrectly for some projects.- The
ideas.txtfile lists three features explicitly planned but not yet implemented: full UI state reload on session restore (partial — editor content and settings restore, but not all UI state), JSON export/import of sessions, and a text display panel for showing the current cue visually during playback.
Stubs / TODOs:
docs/IMPLEMENTATION.mdmentions future work for Web Workers (MP3 encoding), AAC/OGG export, VBR, ID3 tags, batch export, project search/filter, cloud sync, and ElevenLabs adapter.- Coverage thresholds are set at a modest 50-53%, suggesting the test suite covers the most critical paths but not comprehensively.
-
Adapter pattern for TTS engines —
TTSEngineAdapterbase class withgetName(),requiresApiKey(),validateApiKey(),generateSpeech(),getCapabilities()makes adding a new engine straightforward. OpenAI, Google, and gTTS all follow the pattern consistently. -
Two-tier caching architecture — The
TTSCacheService(phrase-level audio blobs) andProjectCacheService(full project snapshots) work together elegantly. The IMPLEMENTATION.md cache key documentation is unusually thorough and accurate. -
Client-side audio pipeline — Everything from decode to mix to encode is done in-browser with no server. The
AudioServiceWAV writer and lamejs MP3 encoder are solid implementations; the buffer loop properly yields to the UI thread every 10 blocks to avoid freezing. -
Robust OpenAI adapter — Handles empty-response retries with exponential backoff (1s/2s/3s), logs extensively, treats bare
.as silence (gpt-4o-mini-tts quirk), correctly gatesinstructionstogpt-4o-mini-ttsonly. -
Test suite quality — The AppController tests (
AppController.test.js) use a custom minimal mock forfetchinstead of a library, which is clean and readable. ThebuildDownloadFilenameandcreateProjectCardtest suites are particularly thorough — they cover edge cases like missing descriptions, generic filenames, and old project data formats. -
Voice/model auto-upgrade — When user selects a gpt-4o-mini-tts-only voice (
ballad,cedar,marin,verse), the model selector auto-upgrades; switching back to a standard voice auto-downgrades. This prevents a class of silent API errors. -
Solar theme mode — The
getSunTimes()/isDaytime()implementation using NOAA solar calculations with geolocation fallback to 6am-6pm is a genuinely nice touch for a personal tool. -
Descriptive filename generation —
buildDownloadFilename()produces sortable ISO-style timestamps with program description, voice, and background track in the filename. Well-tested. -
No build toolchain — Zero configuration to get started; straightforward to fork and self-host.
-
AppControlleris a 2275-line monolith. It owns UI wiring, workflow orchestration, DOM manipulation, project card rendering, theme logic, solar calculations, EQ setup, and more. The form submission handlerhandleSubmit()has three near-identical blocks (~100 lines each) for OpenAI / Google Cloud / gTTS that differ only in parameter extraction — these should be extracted to a shared function. The gTTS and Google Cloud engine paths are copy-paste of the OpenAI path. -
audioBufferToWav()andwriteString()are duplicated across four files —AudioService.js,TTSService.js,OpenAITTSAdapter.js,GoogleCloudTTSAdapter.js, andGTTSAdapter.jsall implement the same WAV writer. This should live in one place (AudioServiceor autils/audio.js). -
Dead syntax-highlighting code —
TextEditorService.applySyntaxHighlighting()andgenerateLineNumbers()are unreachable from the live UI. Either implement the overlay editor or delete these methods. -
listProjects()metadata-only bug — Cards try to readttsOptions,backgroundSettings, andprogramDescriptionfromlistProjects()results, but those fields are only in the full project record fromgetProject(). ThelistProjects()cursor projection should include them, or the card renderer should do agetProject()per entry. -
gTTS adapter is unreachable dead code — The gTTS
<select>option does not exist in the UI HTML, and the Google Translate endpoint is CORS-blocked in browsers anyway. The adapter and its UI elements (gtts-settings,gtts-accent,gtts-slow-speech) inindex.htmlshould either be completed (via a CORS proxy) or removed. -
API keys in
localStorageas plaintext — Acceptable for a personal local tool but worth noting. There's no encryption, and the keys are trivially readable by any JS on the same origin. -
Hash collision risk in
TTSCacheService.generateKey()— The 32-bit djb2-style hash can collide. With 1000+ cached phrases this is low but non-zero. A content-addressable key (SHA-256 viacrypto.subtle) would be more correct, though it would require async key generation. -
SampleAudioServicediscovers samples via N serialHEADrequests (one per expected filename). For 5 samples this is fine; it would need a manifest or batch approach at larger scale. -
No EQ state persistence — EQ settings are lost when the page is reloaded. Projects don't save EQ state either.
-
restoreProject()containsvoice-nameelement reference (line ~363 in AppController) but the Google Cloud voice selector ID in the HTML isgoogle-voice, notvoice-name. This means Google Cloud voice selection is silently not restored from projects. -
Coverage thresholds at 50-53% — Real-world behavior in
handleSubmit(), the mixing pipeline, and the Web Speech API playback path have minimal or no test coverage. -
attenuationdefault mismatch — HTML default is-6dB;saveCurrentProject()usesparseInt(formData.get('attenuation')) || 0which would store0if the field reads-6(since-6is falsy as||'s LHS). The correct guard is?? 0, not|| 0. Similar issue in background settings restore.
Based on ideas.txt, docs/IMPLEMENTATION.md future-enhancement sections, the partially-implemented code, and the overall trajectory of recent commits (v1.9.6–v1.9.9):
Near-term (based on ideas.txt and in-progress code):
- Complete the text overlay/display panel so the current phrase appears on screen during playback — useful for users who miss an audio cue.
- Full UI state persistence on project restore (currently editor + settings restore; EQ, theme, and exact input-mode details don't).
- JSON export/import of project state for cross-device or cross-browser sharing (currently blocked by IndexedDB being origin-local).
Medium-term (based on IMPLEMENTATION.md future-enhancements):
- Web Workers for MP3 encoding to unblock the main thread on long programs.
- EQ settings saved per project.
- ID3 tag embedding in downloaded MP3s (title, artist, etc.).
- Additional export formats (AAC, OGG/Opus).
- Cost/usage estimation display before generation (count phrases × rate).
Architectural (likely needed as the app grows):
- Break
AppControllerinto smaller focused controllers or a state-management approach — as-is, adding a 5th TTS engine would require a 4th near-identical block inhandleSubmit(). - Extract the shared
audioBufferToWav()/writeString()helper to avoid the 4-way duplication. - Fix the
listProjects()metadata gap before adding more project metadata fields. - Possibly remove gTTS or implement it via a CORS proxy backend — the free endpoint is too useful to discard but is genuinely broken in-browser.
Speculative (based on the use-case pattern):
- ElevenLabs TTS adapter (mentioned in existing docs); the adapter pattern makes this straightforward.
- SSML support for Google Cloud / OpenAI (enabling per-phrase prosody control).
- A "preview phrase" mode where a single line is spoken in isolation without full generation.
- Mobile voice-input-to-editor integration (the textarea placeholder already mentions "supports mobile voice input").