About Choir Mixer Development

Choir Mixer

A Visual Release History

v1.0 → v3.8.8  ·  April 2026  ·  davekauffman.ca

Introduction

This document traces the design and development of Choir Mixer from its first working build to the current release. Each version entry describes the problem that prompted the change, what was built in response, and — for the six most visually distinct eras — a reconstructed screenshot of the interface at that moment.

Dave – It had been a long time since I wrote Javascript and a lot has changed since then. The availability of WebAudio lets a web page play digital audio with a variety of capabilities open to the programmer. Slowing down and changing pitch required the use of the SoundTouchJS Library. For an audio guy to find the wealth of audio effects available through modern web browsers was a discovery that enabled this project with enthusiasm.

The project began as a browser-based practice tool for a community choir: a single HTML file that plays separate audio tracks for each vocal part, lets singers adjust the mix, and provides a loop region for drilling difficult passages, and finally the ability to slow down loops without changing pitch, and an explicit control to shift pitch by up to 3 semitones.  It was built and iterated in Claude.ai over a period of roughly two weeks.

The release history contains 62 versions across two major series. The pattern throughout is consistent: a feature or bug is discovered in real use, addressed in a targeted release, and the fix is immediately tested by choir members or the author (Dave). Several bugs — most notably the scrubber 2x position error — required five consecutive releases before the root cause was correctly identified.

Dave – This project made extensive use of Claude.AI working with me as a programmer assistant. I have had several projects like this in my career (Creo VPS, Creo/Kodak Colorflow) where as a UX designer I had a skilled programmer to work with. This interaction with Claude.ai was similar in the sense that the ai is very fast at accessing online documents and shows some impressive capability to find technical means to meet user experience needs. On the other hand, the AI has little worldly experience, and although it can peruse the ux documentation never developed a “feel” for how the app ran, so in that way was like a talented but inexperience programmer, who made development decisions in an expeditious way, but often had to be shown the poor ux that resulted from that decision, and what changes needed to be made.  With my human colleagues they learned my principles of transparency, responsiveness, consistency, and friction-free task accomplishment, and as I pointed out each one to the AI it recognised that this was an important insight but didn’t really start to become a better ux programmer.

Each successive iteration of Choir Mixer revealed inconsistencies or opportunities for reducing friction that powered the next iteration. Iteration speed was amazingly fast, on the order of minutes from specification. I asked to add version numbers to keep track of the executable, and also to provide a development trail as documented below.

Like with my experience as a programmer, checking in working code regularly was critical to the project’s success. When I specified too many changes for one release, when strange bugs would appear, and new inconsistencies in features that worked before would crop up, I instructed the ai to revert to the previous version and take smaller increments, mirroring exactly my experience when trying to change too many things at once in an iteration.

Being an ex-programmer was critical to keep the ai from falling into loops where it could not fix a bug. It took three iterations to find an off-by-2x error, that I couldn’t readily see in the javascript so spent time finding the exact circumstances of interaction that triggered the problem (e.g. mouse down, drag a marker, when I mouse-up it moves to exactly twice the timestamp that I was at). There was a lovely moment when designing the combination of looping and speed-control, the ai started losing the ability to match the loop with the rest of the song, and I made an implementation suggestion to use a buffer length that could include the entire song, but only render the loop, at the exact position it would occupy when the entire song is rendered. This saved a lot of (incorrect) coordinate calculations and converged to my desired ux in the next iteration, when, if the user tuned off loop mode, and the playback started to extend past the rendered loop, it would trigger a calculation of just the missing part of the song.  I think it would be very frustrating for a non-programmer to try to “vibe-code” a complex application as they might not be able to get the AI out of its “death spiral” when it is unable to fix a bug no matter how well you articulate the problem.

This summary was written by the AI by asking to summarise the changes through each iteration, and I’ve edited the places where it knew what I asked but hadn’t instructed it why.

The v1.x Series — Building the Foundation

v1.0    Initial build

The goal was a browser-based tool a choir director could embed in a WordPress page and hand to singers with no installation. The initial build established the core: four-track Web Audio playback, per-track volume and pan, ballistic VU meters, a scrubber with drag handling, a loop region with two handles (but not yet implemented) cue markers with jump-to-next-on-click, admin mode, and a PHP save endpoint backed by a Json config file. It worked on first load. 

pastedGraphic.png

v1.1    Scrubber atomicity and true VU ballistics

Clicking the scrubber while playing caused multiple sets of tracks to play simultaneously, drifting out of sync. Seeking was not atomic — new sources started before old ones were fully stopped. Fixed by nulling onended callbacks before killing sources and scheduling all four tracks together with a 5ms lookahead. True VU ballistics also added at this point: proper attack (~15ms) and decay (~300ms) time constants via requestAnimationFrame.

v1.2    Canvas analog VU meters (short-lived)

Dave asked for real studio-grade metering. A canvas-drawn analog VU meter with a swinging needle on an arc was built per track: green zone (−20 to 0 dBVU), red zone (0 to +3), tick marks with labels, and a peak-hold indicator line, pivoting from the bottom centre. The scrubber was also completely rewritten at the same time — the input[type=range] element replaced with pointer-event drag handling using setPointerCapture.

pastedGraphic_1.png

The analog meters were visually impressive but felt heavy against the minimal dark theme. They were removed in v1.7 and replaced with slim horizontal level bars — a decision that held through all subsequent versions.

v1.3    Scrubber 2x bug (attempt 1), push-to-solo

The scrubber position was reading approximately double what it should — clicking at the halfway point jumped to near the end. The hypothesis was that getBoundingClientRect() measured the element before the iframe layout had settled, returning wrong coordinates. Fixed by calling it fresh on every pointer event. Push-to-solo also added: hold Solo and only that track plays, all others silence and dim; release restores everything.

v1.4    Scrubber 2x bug (attempt 2), multi-song

The 2x bug persisted. New hypothesis: getBoundingClientRect() returns coordinates in the outer page frame when running inside an iframe, so the offset was wrong in an embedded context. Switched to e.offsetX / scrubOuter.offsetWidth, which is always relative to the element itself. Multi-song support added at the same time — you had more than one piece for the choir and needed to switch between them without editing HTML. Auto-pan formula introduced. Dynamic track grid.

v1.5    Scrubber 2x bug (definitive fix), cue markers, parent page config

The 2x bug persisted on pointerup specifically, even though pointermove was correct. Root cause: pointerup was still reading e.offsetX directly. Fixed by tracking lastScrubPct through every pointermove and committing that value on pointerup — no coordinate reading at release. Parent page song config introduced so songs could be defined on the WordPress page rather than inside mixer.html. Cue markers implemented with correct positioning deferred until after buffers load and duration is known. Clicking a marker seeks.

v1.6    Marker click conflict, snap-to-marker

Clicking a marker was also triggering the scrubber position logic, causing a jump to the wrong position. Fixed by explicitly checking the event target before the scrubber logic runs. Snap-to-marker added: scrubbing within 2.5% of a marker locks to it on both pointerdown and pointermove.

v1.7    Root cause of 2x bug, slim VU bars

The 2x bug appeared in a reproducible form on macOS Safari. Root cause finally found: playStartTime = when – offset — a single variable that mixed AudioContext clock time with audio offset, compounding errors across every seek and restart. Replaced with two separate variables (playStartCtxTime and playStartOffset), making currentOffset() unambiguous. The analog VU meters were removed at the same time, replaced with slim 3px horizontal level bars with peak-hold tick.

pastedGraphic_2.png

v1.8    Relative normalisation, pastel track colours

Tracks were playing at different audio levels depending on the source recordings, often needing manual adjustment. Added something we called relative normalisation: a single pass finds the absolute peak across all buffers and scales all tracks equally so the loudest just touches 0dBFS. Per-track pastel colours introduced — dusty rose, sage green, sky blue, warm amber — on card borders, level bar fill, and peak tick.

v1.9    Eager load

The page felt slow — the user had to wait for Play before the mixer was ready. Eager load implemented: loadSong() immediately calls ensureAudio() → buildAudioGraph() → loadBuffers() without waiting for Play. Status shows “Ready” with duration known and markers positioned before the user presses anything. Card border width increased from 1px to 2px for better visibility.

The v2.x Series — Real-World Testing

v2.0    Marker labels row, keyboard shortcuts

The cue marker triangles were hard to read without labels visible above them. A dedicated label row added above the timeline, positioned at the same percentage as each caret. Spacebar added for play/pause from anywhere on the page. Tab/Shift-Tab navigation between markers with a 0.25s dead zone on shift-tab so users don’t re-trigger the marker you just passed. Keyboard hint row shown below the scrubber.

v2.1    iOS audio recovery, loop region

Occassionally the mixer stopped producing audio on iOS mid-session — the AudioContext had suspended due to the browser’s autoplay policy after a tab switch or phone interruption, with no visible way for the user to recover. A yellow recovery banner added; tapping it calls ctx.resume() and restarts playback from the correct position. A 2-second poll catches cases where the browser doesn’t fire statechange reliably. The loop region was also introduced at the same time — blue drag handles, filled shaded zone, Loop button in transport — which was the feature the choir needed most for drilling sections.

v2.2    Loop in-point audition, Shift-Tab dead zone

Dave – Adjusting the timing of the loop is quite important to choir users, when trying to learn a specific phrase or note-sequence, so being able to place the loop marker at an exact musical spot became important. When you dragged the loop in-point handle while playing, the audio continued from wherever it had been rather than from the new in-point. You couldn’t hear whether the new entry was correct without stopping and restarting. Fixed: dragging the in-point while playing immediately restarts from the new position so you hear the entry as you fine-tune it. Shift-Tab dead zone added — if within 500ms after a marker, it skips that marker and goes to the previous one.

v2.3    Loop drag backward without crackling, unified triangles

Dragging the in-point backward while playing caused crackling and audio artefacts as the position jumped backward through already-playing audio. Fixed with a pause-on-backward-drag approach: audio pauses immediately when the handle moves backward, auto-resumes 300ms after movement stops, no mouse-up required. Triangle directions unified: cue markers are amber ▾ pointing down, loop handles are blue ▲ pointing up.

v2.4    Triangle clipping, second song

The loop handle triangles were being visually clipped by overflow: hidden on their parent rows, making them appear truncated or invisible. The overflow restriction removed and triangles resized to 8×7px. Pink Sky added as a second song in the default configuration.

v2.4.1    Logic-style loop scrub, normalised solo

Dave described the scrubber behaviour in loop mode you expected: when you drop the thumb inside the loop region, playback should start from exactly where you dropped it, somewhat but not completely unlike Logic Pro — not always snap to the in-point. Fixed with a one-line check on pointerup. Normalised solo also added: soloStart() finds the maximum of all saved gain values and boosts the soloed track to that level, so the soloed part is always clearly audible. Dave – this was an idea that I hadn’t seen before, kind of like Solo on an audio system but between the PFL and AFL levels, choosing instead the highest volume of any track as the best Solo level.

v2.4.2    Separate loop snap threshold, admin mode formalised

The loop handle snap threshold felt too coarse — handles were jumping to markers when you were trying to place them between markers for fine section work. A separate tighter threshold introduced for loop handles (1.5%) distinct from the scrubber (2.5%). Admin mode formalised at the same time: type a code within 2 seconds to toggle, version label glows purple.

v2.5    Marker edits in admin mode

Manually editing marker times in the HTML source was error-prone and broke the deployment model — every marker change required a file upload. You needed to be able to add, position, and label markers from within the mixer itself. Marker CRUD (short for Create/Rename/Update, and Delete) added in admin mode: click on empty marker row to create at that time, drag existing markers for coarse positioning (saves on pointer-up), double-click to edit precisely via dialog.

pastedGraphic_3.png

v2.5.1    Marker restore fix, precision admin controls

After a save and reload cycle, markers were appearing at time zero rather than their saved positions. Root cause: the seconds field was occasionally stored as a string or lost during JSON round-trip. Fixed by always re-parsing seconds from the time string on restore. Loop handles and marker drags now bypass snap in admin mode so precise placement isn’t fought by the snap logic. Sheet music URL field added to admin.

v2.6    Markers at zero on load, volume persistence

Markers were appearing at position zero on initial load before snapping to their correct positions a frame later. Root cause: renderMarkers() was called before duration was known. Deferred to inside loadBuffers() after duration is confirmed. Channel volumes were also not surviving page refresh — saveGains() wired to every fader move and restoreGains() called on song load.

v2.7    iOS audio: gesture requirement

The mixer would not produce audio on iOS Safari at all. iOS requires the AudioContext to be created inside a direct user gesture handler. The AudioContext was being created at module load time. Moved to create it only inside togglePlay(). Buffer decoding moved to OfflineAudioContext which requires no gesture.

v2.8 — v2.8.2    iOS audio session, unsaved changes protection

iOS was still not reliable — the “silent” switch was defeating playback even when the gesture timing was correct. Added apple-mobile-web-app-capable meta tag and latencyHint: ‘playback’ on AudioContext creation to signal intentional media playback, upgrading the audio session category to match Apple Music. In v2.8.2: adminDirty flag, Save button shows asterisk when dirty, unsaved-changes dialog on exit admin with Save & Exit or Discard.

v2.9 — v2.9.1    URL field flow, dirty state management

The URL field required two distinct steps for admin users to commit: type the URL, press Enter, then separately press Save Song Details. The two-step flow was invisible to new users and easy to forget. URL field now commits on Enter or blur, with a hint line. Save Song Details button hidden by default, shown only when dirty, always reset cleanly on mode entry and exit. Generally moved to immediate commit of admin ui changes

.v2.9.2    Speed slider, Export Mix, master limiter

Choir members struggle to learn fast or complex passages at full tempo. A speed slider added (25–100%) using playbackRate on the Web Audio BufferSource nodes — pitch drops with speed at this stage, which was acknowledged as a known tradeoff for the first implementation. Export Mix WAV added using OfflineAudioContext, allowing offline practice at any speed. Master bus limiter added (DynamicsCompressorNode, −1dBFS threshold, 20:1 ratio, zero attack) to prevent clipping when multiple loud tracks are summed. Loop handle minimum gap relaxed from 2% to 0.1%.

pastedGraphic_4.png

The v3.x Series — SoundTouch and the Full Feature Set

v3.0    SoundTouchJS pitch-preserving speed

The playbackRate approach was confirmed to lower pitch with speed — choir members noticed immediately. SoundTouchJS loaded from CDN, replacing the BufferSource tempo approach with WSOLA time-stretching that preserves pitch. Each track gets a PitchShifter wrapping its AudioBuffer. Seeking requires recreating the shifter at the new position. iOS broke immediately — the ScriptProcessorNode approach was not compatible with iOS audio policy.

v3.0.1 — v3.0.3    iOS fix, progress bar, async batch processing

Three rapid iterations to fix iOS compatibility. v3.0.2 resolved it by using SoundTouch as pure JavaScript with no Web Audio nodes: extract samples in chunks using SimpleFilter, de-interleave the output Float32Array, hand the result to a standard BufferSource. iOS handles this correctly. v3.0.3 added async batch processing so the browser can repaint between chunks, making “Calculating…” and the amber progress bar actually visible in real time.

Dave here – this was a good example of interaction with ai – if I have to wait I need to know how long, and since there are a finite, well-established number of samples to calculate, the code can update a calculation progress indicator with great accuracy.  Only after seeing how long it took to render a pitch-corrected change in speed did I hit on the optimisation that since choir members typically only slow playback down during a difficult section that they are looping, we only need to render the loop rather than the entire song.

v3.1 — v3.1.4    LIM indicator, loop-zone sparse render, canon fix, render cache, pendingExitLoop

A series of architectural releases addressing the speed/loop interaction. Loop-zone sparse render: when looping at reduced speed, only the loop zone frames are WSOLA-processed rather than the full song, making the speed control feel nearly instant for short loops. Canon bug fix: loop-zone geometry computed once before the per-track loop so each track’s silence-to-audio boundary is identical. Render cache keyed on tempo and isFull. pendingExitLoop flag: when loop is disabled during playback at reduced speed, plays to end of loop zone then processes full song and continues. LIM indicator changed from a badge to a small 6px red dot beside the status text.

v3.3 — v3.3.9    Waveform display

A collapsible waveform display added below the loop handles: renders the mixed audio through the limiter at display resolution using OfflineAudioContext in the background, with a generation counter to discard stale renders. The waveform shows the loop zone highlighted in blue, updates on vol/pan change (debounced), after speed renders, and on loop toggle. A green playhead cursor tracks position in real time and is draggable for seeking. Several spacing iterations followed to get the vertical rhythm right.

pastedGraphic_5.png

v3.4    Error box clear, renderGeneration counter

Two bug fixes: the error box now clears on successful song load, so a previous “Still loading…” message doesn’t persist after the song becomes ready. renderGeneration counter added to applySpeed() — each render checks whether its generation matches the current one at every batch boundary; stale renders from rapid speed changes discard silently, fixing zombie audio where multiple audio outputs competed simultaneously.

v3.5    Choir Mixer name, Stop to in-point, Mute button

Subtitle updated from “vocal mixer” to “Choir Mixer”. Stop in loop mode now returns the playhead to the loop in-point rather than the song start. Mute button added below Solo: it latches (click to mute, click again to restore), shows “Mute” or “Muted” at fixed width, turns red when active, the VU meter drops to zero naturally because the analyser taps after the gain node, the fader position is unaffected, and Solo overrides Mute while held.

v3.6 — v3.6.3    Pitch shift dropdown

Pitch shift added: a dropdown of −3 to +3 semitones plus Original. SoundTouch pitchSemitones parameter threaded through all render paths. The render cache extended to include pitch. At v3.6.1: pitch had no effect at 100% speed because the full-speed shortcut bypassed SoundTouch entirely — fixed. At v3.6.2: the loop exit condition was missing the pitch check. At v3.6.3: null crash on cancelled render in loop zone path.

v3.7    Colour state machine for Speed and Pitch

Speed percentage and Pitch dropdown now have three visual states: grey at default value, amber immediately when changed while processing, green when non-default and settled. Only the touched control goes amber — changing speed doesn’t amber the pitch dropdown. The singulr/plural label “semitone/semitones” corrected. v3.7 was the version deployed to the choir for beta testing.

The v3.8.x Series — Song Management

v3.8    Song CRUD in admin mode, server-managed song list

Songs were defined in the WordPress page HTML, requiring file edits and uploads to add or rename a song. Song management added to admin mode: Add New Song dialog (title, optional sheet URL, track count 2–6, track name and URL per track), Rename, Delete with confirmation. Songs stored in mixer-config.json as a songList array. On load, config songs replace page-defined songs if present; page songs remain as fallback for existing deployments. The file can now function as a standalone web app without a host page.

v3.8.1 — v3.8.3    Per-track load status, honest URL validation, better errors

Tracks now load sequentially with status showing “Opening Soprano…” then “Loading Soprano…” per track. The URL validator in the song edit dialog replaced no-cors HEAD with a Range: bytes=0-0 GET request, which correctly shows a red cross for CORS-blocked or inaccessible files rather than a false green checkmark. Load error messages updated to include track name, HTTP status code, and full URL, making it possible to diagnose problems without opening the browser console.

v3.8.4    Exit admin reloads song, controls always visible

Exiting admin mode now reloads the current song automatically, picking up any track URL edits made during the session and clearing any stale error messages. Admin controls in the song dropdown now always visible without requiring hover — the purple border context already signals admin mode, so the controls should be immediately usable.

v3.8.5    Auto-save replaces Save Song Details

The Save Song Details button created a double-confirm step: make changes in the song edit dialog, click Save Changes, then also click Save Song Details in the transport bar. The button removed. Every change now calls autoSave(), debounced 800ms, which writes to the server silently. The status area briefly shows “Saving…” then “Saved ✓” or “Save failed ✗”. The beforeunload warning removed — nothing can be lost.

v3.8.6    Admin pill indicator, save detects actual change

A small purple pill “⬡ Admin Mode — click to exit” appears in the top-left corner when admin mode is active, clicking it exits. No need to retype the code to exit. In the song edit dialog, the Save Changes button starts greyed out when opening an existing song and only activates when an actual change is detected — title, sheet URL, track count, track names, or track URLs. For new songs the button is always active.

pastedGraphic_6.png

v3.8.7    Export respects mute state

Export Mix was producing audio from muted tracks — a user reported wanting to export just their part by muting the other three. Export now checks the mute state per track and sets gain to zero for muted tracks. The user scenario works: mute Soprano, Alto, and Bass, export, receive a WAV file of Tenor only at its current volume setting.

v3.8.8    Pan save and restore fixed

A user adjusted pan settings and found they were lost on refresh. Investigation showed pan was never being persisted: saveGains() only stored gain, setPan() never called saveGains(), and restore always used the autoPan() formula. Fixed in three places: saveGains() includes pan, setPan() calls saveGains() after updating, and restoreGains() returns objects with both gain and pan fields. Existing localStorage entries without pan fall back to autoPan() gracefully until the user adjusts pan once.

Choir Mixer  ·  v1.0 → v3.8.8  ·  davekauffman.ca

Loading