1Overview
Truly maps a listener's self-reported mood and energy to a point in valence and arousal space, plans a straight line from that point to a goal chosen by direction, and fills the line with recordings whose published valence, arousal and tempo place them close to it. Two optional models, a face expression model and a voice model, may shift the start point by a small, bounded amount. Everything runs in the browser.
| Hosting | Static files on GitHub Pages; no server code |
|---|---|
| Client | Plain ES modules, no build step, no framework |
| Recommendation | Geometry, a cost function and weighted sampling, in music.js |
| Models | Two ONNX models distilled from LAION teachers, run with ONNX Runtime Web (WebAssembly) |
| Face model | EfficientNet-B0, 4.06 M parameters, 16.2 MB, 224 × 224 input, 40 outputs |
| Voice model | Log-mel CNN, 420,410 parameters, 2.4 MB, 80,000-sample input, 2 outputs |
| State | In memory per visit; optional localStorage, off by default |
| Offline | Service worker caching public files only |
Constraints that shape the design
| Constraint | Consequence |
|---|---|
| Static hosting, no backend | All computation in the client; models in ONNX; catalogue as a JSON file |
| No personal data leaves the device | No analytics or third-party requests; recordings self-hosted; inference on device |
| Non-clinical scope | Models only adjust a start point; no output is shown as a reading of the person; mood answers never stored |
| Phones as the main target | Small models, lazy loading, single-threaded WebAssembly, offline support |
| Small catalogue (18 recordings) | Quantile-based placement of answers; candidate pooling; forward re-ordering of paths |
2System structure
Components
/Truly/app/
/Truly-music/
| Module | Responsibility | Depends on |
|---|---|---|
app.js | Views, event wiring, session state, player, preferences, history | music.js, store.js; lazily face.js, voice.js |
music.js | Pure recommendation functions: placement, goals, planning, cost, selection, swaps, nudges, explanations, quality metrics | none |
store.js | The only module that touches localStorage | none |
face.js, readout.js | Camera capture, face detection, face model, readout to a tone | ONNX Runtime Web |
voice.js | Microphone capture, resampling, loudness normalisation, voice model, readout | ONNX Runtime Web |
sw.js | Caching for offline use | none |
music.js, readout.js and store.js have no DOM dependencies and run unchanged in Node.js, which is how they are tested.
Request flow of one session
| # | From → to | Message |
|---|---|---|
| 1 | Listener → app.js | Mood and energy, 1 to 5 |
| 2 | app.js → face.js or voice.js | Optional: readExpression() or readVoice(); the runtime and model are fetched on first use; a tone or {valence, arousal} is returned and kept in memory |
| 3 | app.js → network or cache | tracks.json |
| 4 | app.js → music.js | startPoint, nudgeStart, nudgeVoice, goalPoint, pathPoints |
| 5 | app.js → store.js | Favourites, "not for me" and listening counts, if saved |
| 6 | app.js → music.js | buildPath(catalogue, points, prefs) returns the ordered path |
| 7 | app.js → network or cache | audio/*.mp3 over range requests |
| 8 | app.js → store.js | savePath, recordListen, only while saving is on |
3Catalogue and notation
tracks.json in the Truly-music repository lists every recording.
| Field | Type | Meaning |
|---|---|---|
key | string | Stable identifier, also the audio file name |
title, composer, performer | string | Credits |
instruments | string[] | Any of piano, strings, orchestra, winds |
audio | string | Relative path to a 96 kbps MP3, loudness-normalised |
seconds | number | Duration |
valence, arousal | number | From MCUD 1.0 (Du et al., 2026), averaged when a recording covers several entries |
bpm | number or null | Tempo from MCUD 1.0 |
licence, licence_url, source | string | Licence and Wikimedia Commons page |
mcud | number[] | MCUD 1.0 song identifiers |
Notation
A piece s has position (vs, as). The catalogue's valence and arousal have standard deviations σV and σA and empirical quantile functions QV and QA. All distances are in standardised units, so a step of mood and a step of energy weigh the same:
4Recommendation engine
All functions are in docs/app/music.js and are pure: the same inputs and random source give the same path.
4.1 Start point
Each answer ℓ ∈ {1, …, 5} selects a quantile level qℓ ∈ (0.08, 0.25, 0.50, 0.75, 0.92):
Placing answers by quantile of the catalogue, rather than by a fixed mapping to the annotation scale, keeps every answer inside the region where recordings exist.
4.2 Optional nudges
The camera result is a tone τ and moves valence only. The voice result (v̂, â) is in standard deviations from everyday speech (7.6) and passes through c(x) = max(−1, min(1, x / 1.5)):
4.3 Goal
| Direction | Goal valence | Goal arousal |
|---|---|---|
| calmer | v0 + 0.4 (max(v0, QV(0.55)) − v0) | min(a0, QA(0.25)) |
| brighter | max(v0, QV(0.70)) | a0 + 0.4 (QA(0.45) − a0) |
| steady | v0 | a0 |
The default direction is steady when mood ≥ 4, calmer when mood ≤ 2 and energy ≥ 4, and brighter otherwise. The listener can always override it.
4.4 Planned points
Paths have n = 4, 6 or 9 pieces; "slow down" uses round(1.5 n) and keeps the first piece. The heading h is the unit vector from p0 to pg in standardised units. Starting near the listener's state and moving gradually follows the iso principle (Heiderscheit and Madson, 2015; Starcke and von Georgi, 2024).
Try it: the engine on the real collection
Path and explanations
- Average step
- Largest step
- Step variation
- Largest tempo change
4.5 Cost of a candidate
For candidate s at planned point pi, with previous piece r and Δ the standardised step from r to s:
[·] is 1 when the condition holds, and λs ∈ [−1, 1] is the clipped difference between full plays and early skips, divided by 3 (section 9). Terms involving r are zero for the first piece. cost() returns every term separately; the chart below plots them for the path above.
Cost terms of the cheapest candidates, per step
4.6 Selection
buildPath(catalogue, points, keep, prefs, rng):
path ← keep
for each planned point p_i not covered by keep:
candidates ← pieces not in path, performer not in path, not ruled out
K ← the 8 candidates with the lowest C(s)
pool ← members of K with rank < 4 and C ≤ C_min + 0.25
choose from pool with weights 1 / (1 + rank)² # 1, 1/4, 1/9, 1/16
append the choice to path
return path sorted by (v_s/σ_V, a_s/σ_A) · h # forward order
Sampling among near-ties varies paths between sessions without choosing poor matches. The final sort guarantees monotone progress along the heading, even when a later point selected a piece slightly behind an earlier one. replaceSong repeats the choice for one index, first restricted to candidates whose projection on h lies between the neighbours' projections, then without that bound, and sorts again.
4.7 Explanations
For piece i > 0, the standardised differences from piece i − 1 are compared with ±0.15: arousal gives "calmer" or "livelier", valence "brighter" or "more reflective". The first piece "matches the mood and energy you described"; the last piece of a moving path "is where the path settles". A note is added for a favourite, a matching preferred instrument, or λs ≥ 2/3.
4.8 Path quality
| Metric | Definition, over the standardised steps ‖Δj‖ |
|---|---|
| Average step | δ̄ = meanj ‖Δj‖ |
| Largest step | maxj ‖Δj‖ |
| Step variation | sdj ‖Δj‖ / δ̄ (0 when all steps are equal) |
| Largest tempo change | maxj |bpmj+1 − bpmj| |
These follow the smoothness and evenness criteria used for mood playlists (Donnelly and Gaur, 2022). Over all 25 answer pairs, 3 directions, 3 lengths and 5 seeds (1,125 paths) with the current catalogue:
| Metric | Median | 90th percentile | Maximum |
|---|---|---|---|
| Average step | 0.48 | 0.80 | 1.12 |
| Largest step | 0.92 | 1.84 | 2.01 |
| Step variation | 0.68 | 0.99 | 1.24 |
| Largest tempo change (bpm) | 21 | 40 | 44 |
The tests fail if any path exceeds a largest step of 2.2, an average step of 1.2 or a tempo change of 45 bpm.
5On-device inference runtime
| Runtime | ONNX Runtime Web 1.30.0, WebAssembly backend with SIMD (ort-wasm-simd-threaded.wasm, 14.2 MB) |
|---|---|
| Threads | 1 (ort.env.wasm.numThreads = 1) |
| Loading | Runtime and models load through dynamic import() only when the camera or voice is first used |
| Caching | Service worker, cache first after the first download |
GitHub Pages cannot send the Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers needed for SharedArrayBuffer, so multi-threaded WebAssembly is unavailable; one thread is set explicitly to avoid a failed attempt.
Inference time, median of five runs after one warm-up, on one thread of a 2.2 GHz Xeon in Node.js 20 with the same runtime:
| Model | Input | Size | Time per run |
|---|---|---|---|
| UltraFace RFB-320 | 1 × 3 × 240 × 320 | 1.3 MB | 377 ms |
| Truly face model v1 | 1 × 3 × 224 × 224 | 16.2 MB | 191 ms |
| Truly voice model v1 | 1 × 80,000 | 2.4 MB | 132 ms |
6Face pipeline
6.1 Inference
- FrameDrawn from the video into a canvas; never stored.640 × 480
- NormaliseResize, then (x − 127) / 128 per channel.1 × 3 × 240 × 320
- UltraFace RFB-320Face probabilities and decoded boxes for 4,420 anchors. Best box above 0.7; no NMS needed.
- CropSquare of side 1.5 max(w, h) at the box centre; ImageNet mean and std.1 × 3 × 224 × 224
- EfficientNet-B0Classifier replaced by 1280 → 40; EmoNet-Face categories in alphabetical order.1 × 40
- ReadoutSeven class scores (6.2).7
- AggregateUp to 30 attempts 0.4 s apart, at most 10 frames, at least 3; mean, offsets, argmax, tone.
6.2 Readout
With model output z ∈ ℝ40 and per-category training statistics μ, σ from readout.json, and Gk the categories mapped to class k in mappings/emonet_categories.json:
Happy and surprise map to the tone bright; angry, disgust, fear and sad to heavy; neutral to calm.
6.3 Training by distillation
| Teacher | Empathic-Insight-Face-Large: 40 MLP heads (1152 → 1024 → 512 → 256 → 1, ReLU, dropout 0.2) on SigLIP 2 so400m patch16-384 embeddings (1152-d); about 470 M parameters in total |
|---|---|
| Student | EfficientNet-B0 with a 40-output head, 4.06 M parameters |
| Data | 79,999 face images: 40,000 FairFace, 20,000 EmoNet-Face Big, 19,999 EmoNet-Face Binary; 76,000 train, 3,999 validation |
| Targets | Teacher scores for all 40 categories, standardised per category |
| Loss | Smooth L1 (β = 1) |
| Optimiser | AdamW, weight decay 0.02; one-cycle learning rate, peak 10−3, 10% warm-up; batch 128; 8 epochs; mixed precision |
| Augmentation | Random square crop of 80 to 100%, horizontal flip (0.5), rotation ±10°, greyscale (0.25), downscale to 48, 64 or 96 px and back (0.25), brightness × U(0.8, 1.2) plus U(−0.2, 0.2) on normalised pixels |
| Compute | Two NVIDIA T4 GPUs on Kaggle, 31.8 minutes including evaluation |
No FER-2013 data was used for training or calibration. The same network trained directly on EmoNet-Face Binary expert labels reached 30.7% on FER-2013 PrivateTest; distillation from the teacher's dense 40-dimensional scores reached 51.0%.
6.4 Calibration
The offsets b ∈ ℝ7 are fitted by coordinate ascent on CREMA-D video frames from actors with even identifiers, maximising macro F1 over the classes present: four rounds over the classes, each searching a 61-point grid on [−3 sd(g), 3 sd(g)]. Surprise does not occur in CREMA-D and keeps b = 0.
6.5 Evaluation
| Test set | Images | Accuracy | Macro F1 |
|---|---|---|---|
| FER-2013 PrivateTest | 3,589 | 51.0% | 0.422 |
| FER+ PrivateTest | 3,350 | 59.5% | 0.461 |
| CREMA-D frames, odd actors | 7,066 | 36.2% | 0.376 |
On EmoNet-Face HQ (2,500 images, four expert ratings per category) the mean Spearman correlation with expert intensity is 0.458, and the mean AUROC for categories rated 2 or higher is 0.793. Per-class results are in the face model card.
7Voice pipeline
7.1 Capture
getUserMedia is called with echoCancellation: true, noiseSuppression: false and autoGainControl: false, so the signal keeps its natural dynamics. A ScriptProcessorNode with 4,096-frame buffers collects 5 fs samples at the device rate, and the stream stops immediately afterwards. An OfflineAudioContext resamples to 16 kHz and the first 80,000 samples are kept.
7.2 Loudness normalisation
Applied identically in training (voice_teacher.py) and in the browser (voice.js). For a signal x split into 400-sample frames with RMS values et:
The output is γx, zero-padded or cut to 80,000 samples. If the loudest frame is below 0.002 the clip is rejected as silence. Normalising over the sounding frames only makes the level independent of pauses, and removes distance to the microphone as a source of variation.
7.3 Model
The spectrogram is computed inside the network, so the ONNX graph takes raw samples.
| Stage | Operation | Output shape | Parameters |
|---|---|---|---|
| Input | 5 s at 16 kHz | 1 × 80,000 | |
| DFT | Conv1d, 402 fixed filters of length 400, stride 160 | 1 × 402 × 498 | fixed |
| Power | re² + im² | 1 × 201 × 498 | |
| Mel | Fixed 64 × 201 Slaney filter bank, 50 Hz to 8 kHz | 1 × 64 × 498 | fixed |
| Log, standardise | log(x + 10−6), zero mean and unit variance per clip | 1 × 1 × 64 × 498 | |
| Block 1 | 2 × (3 × 3 conv, batch norm, ReLU), 2 × 2 max pool | 1 × 24 × 32 × 249 | 5,496 |
| Block 2 | same | 1 × 48 × 16 × 124 | 31,296 |
| Block 3 | same | 1 × 96 × 8 × 62 | 124,800 |
| Block 4 | same | 1 × 128 × 4 × 31 | 258,560 |
| Pool | Global average over frequency and time | 1 × 128 | |
| Head | Dropout 0.2, linear | 1 × 2 | 258 |
| Total | 420,410 |
The DFT filters, with the periodic Hann window w[n] = 0.5 − 0.5 cos(2πn / 400), are:
Writing the short-time Fourier transform as a strided convolution uses only standard ONNX operators (Conv, Mul, Add, MatMul, Log), which every ONNX Runtime backend supports. Per-clip standardisation of the log-mel image removes any level offset left after 7.2.
The front end, run in this page
A JavaScript copy of 7.2 and of the DFT, mel and log stages, applied to five seconds of a recording from the collection. It matches the model's own filter bank to 2 × 10−9 and its log-mel output to 2 × 10−5. No microphone is used; the recording is downloaded only when you press the button.
7.4 Teacher
Empathic-Insight-Voice-Small provides one regression head per attribute on BUD-E-Whisper encoder states. The audio is padded to Whisper's 30-second window, giving 1,500 × 768 hidden states; each head flattens them and applies 1,152,000 → 64 → 64 → 32 → 16 → 1 with ReLU. Only the Valence and Arousal heads are downloaded and run; no other attribute is ever computed. With the encoder, the teacher has about 235 M parameters, 560 times the student.
7.5 Training by distillation
| Windows | 5 s at 16 kHz after the loudness step; windows with RMS below 0.01 skipped |
|---|---|
| Data | 12,932 windows: 3,592 LAION's Got Talent (expressive acted speech), 6,000 Emolia YODAS (natural English speech), 3,340 CREMA-D actors with even identifiers |
| Split | 12,286 training, 646 validation (random); test: 3,457 CREMA-D windows from actors with odd identifiers |
| Targets | Teacher valence and arousal, standardised with the training mean and standard deviation |
| Loss | Smooth L1 (β = 1) |
| Optimiser | AdamW, weight decay 0.01; one-cycle learning rate, peak 2 × 10−3, 15% warm-up; batch 64; 30 epochs; best validation checkpoint |
| Augmentation | Circular shift up to ±0.5 s, gain U(0.5, 1.6), Gaussian noise at 15 to 40 dB SNR (probability 0.7), clipping to [−1, 1] |
| Compute | One NVIDIA T4 on Kaggle, 19 minutes |
7.6 Readout
The output z is in standard deviations of the training targets. The training mix leans towards expressive acted speech, so its mean sits above everyday speech; readout.json re-centres on the teacher's mean m over natural YODAS speech, so 0 means "as lively, or as bright, as ordinary speech":
The engine then applies the bounded nudge of 4.2. Arousal carries most of the weight because the teacher's valence depends largely on lexical content, which Truly never uses.
7.7 Evaluation
| Set | Windows | r, arousal | r, valence |
|---|---|---|---|
| Validation | 646 | 0.897 | 0.870 |
| CREMA-D, odd actors | 3,457 | 0.787 | 0.139 |
Pearson correlation with the teacher. CREMA-D actors read the same lexically neutral sentences in every emotion, so the teacher's valence barely varies on it, which explains the low valence correlation there.
8Export and verification
Both students are exported with torch.onnx.export, opset 17, a dynamic batch axis, input names image and audio, and output name scores. The exports are checked in three ways:
- For the voice model, PyTorch and ONNX Runtime Web outputs on the same input agree to within 10−4.
- The JavaScript front end in 7.3 matches the model's filter bank to 2 × 10−9 and its log-mel output to 2 × 10−5.
tests/models.test.mjsloads every shipped model with the vendored runtime and checks input names, output shapes and that outputs are finite.
9Client state and storage
| State | Where | Lifetime |
|---|---|---|
| Mood and energy answers, camera tone, voice result | session in app.js | Current check-in only; never written anywhere |
| Video frames, audio samples | Canvas and typed arrays | Discarded after inference; audio buffers zero-filled |
| Preferences, "not for me" | Memory, and localStorage when saving is on | Visit, or until deleted |
localStorage keys, written only by store.js and only while truly.saving.v1 is true:
| Key | Schema |
|---|---|
truly.saving.v1 | true; absent means off |
truly.paths.v1 | [{ id, at, direction, pieces: [{ key, title, composer }], played: [key] }], last 50 |
truly.favourites.v1 | [{ key, title, composer, performer }] |
truly.not-for-me.v1 | [{ key, title, composer, performer }] |
truly.preferences.v1 | { instruments: [string], length: "short" | "standard" | "long" } |
truly.listening.v1 | { [key]: { full, early, title } }, counts capped at 20 |
A play counts as full on the audio ended event, and as early when the listener leaves the piece by choice (next, previous, another piece, or swapping the playing piece) within 30 seconds. The listening term is λs = clip(fulls − earlys, −3, 3) / 3. Turning saving off stops writes; the Privacy view exports all keys as JSON or deletes them.
10Playback and offline use
The player is one <audio> element, and recordings are served over HTTP range requests. Its transitions:
| State | Event | Next state |
|---|---|---|
| Idle | play(i) | Loading |
| Loading | playing | Playing |
| Loading | No data for 15 s | Stalled (message shown, still waiting) |
| Playing | waiting | Loading |
| Playing or Loading | error | Retry: reload once after 2 s at the same position, then the next piece |
| Playing | ended | Next piece, or Idle after the last |
| Retry | 3 failures in a row | Idle, with a message |
tracks.json is fetched with a 10 s timeout and up to three attempts with exponential back-off. On canplaythrough, the next recording is preloaded in a detached Audio element. The Media Session API exposes metadata and the play, pause, next, previous and seek actions.
sw.js (scope /Truly/app/) caches only public files:
| Request | Strategy | Cache |
|---|---|---|
App files, fonts, tracks.json | Network first; the cached copy after 4 s or on failure | truly-shell-v1 |
models/, vendor/ | Cache first | truly-models-v1 |
*.mp3 | From the cache if the listener kept it, with range responses built from the cached blob; otherwise the network | truly-offline-music |
While the browser is offline, pieces not in truly-offline-music are excluded from selection, so new paths use only kept recordings.
11Testing
node --test tests/ runs in GitHub Actions on every push and pull request.
| File | Verifies |
|---|---|
music.test.mjs | Path length; no repeated performer; monotone progress along the heading, including after swaps and slowing down; bounded nudges; preferences shift selection and ruled-out pieces never appear; explanations; catalogue loading retries; quality metrics and limits; valid instrument tags |
store.test.mjs | Nothing written while saving is off; upserts, caps and deletion; preferences, "not for me" and listening counts |
face.test.mjs | Readout groups match the category mapping; neutral scoring; frame averaging; tones |
voice.test.mjs | Loudness normalisation level, silence rejection, peak limit and length; centring of the voice readout |
models.test.mjs | Every shipped ONNX model loads in ONNX Runtime Web and has the expected input and output shapes |
Browser behaviour (playback failures, offline use, preferences) was checked in headless Chromium with Playwright.
12Design decisions
| Decision | Alternatives considered | Reason |
|---|---|---|
| Static site, all logic in the client | Server API | No personal data can leave the device if there is nowhere to send it; free hosting |
| Published valence and arousal per recording | Predicting them from audio | Inspectable, citable values; no model error in the catalogue |
| Quantile placement of answers | Linear mapping to the annotation scale | Every answer lands where recordings exist, whatever the catalogue's spread |
| Straight-line plan, greedy selection, forward sort | Shortest path or dynamic programming over the catalogue | With fewer than 20 pieces, greedy choice plus re-ordering gives monotone paths in microseconds and is easy to explain |
| Distilled students | Training on human labels directly; running the teachers | Direct training reached 30.7% on FER-2013; the teachers have about 470 M and 235 M parameters and cannot run on phones |
| Dense teacher targets (40 face categories) | Hard class labels | More signal per image and a readout that can change without retraining |
| DFT as a fixed convolution | The ONNX STFT operator; features computed in JavaScript | Standard operators run on every backend; one file holds the whole pipeline |
| Loudness normalisation in training and inference | Gain augmentation only | Removes microphone distance as a variable at its source |
| Energy-first voice nudge | Equal weights | The teacher's valence depends on words, which are never used |
| Bounded, additive nudges | Letting a model choose the start | The listener's own answers always lead, as the scope requires |
| Single-threaded WebAssembly | Threads, WebGPU | GitHub Pages cannot enable cross-origin isolation; WebGPU support on phones is uneven |
| Opt-in, per-path offline audio | Caching everything | A path is about 20 MB; the listener decides what uses their storage |
13Code map and extension points
| File | Contents |
|---|---|
docs/app/app.js | Views, session, player, preferences, history, offline controls |
docs/app/music.js | Recommendation engine (section 4) |
docs/app/store.js | Storage (section 9) |
docs/app/face.js, readout.js | Face pipeline (section 6) |
docs/app/voice.js | Voice pipeline (section 7) |
docs/app/sw.js, manifest.webmanifest | Offline use and installation (section 10) |
training/face_teacher.py, face_distill.py | Face teacher scoring, distillation, calibration and export |
training/voice_teacher.py, voice_distill.py | Voice windows and teacher scoring, distillation and export |
music/recordings.json, prepare_audio.py | Catalogue sources and audio preparation |
tests/ | Section 11 |
Replacing a model. Any model that keeps the ONNX contract works without code changes: input image [N, 3, 224, 224] with ImageNet normalisation and output scores [N, 40] in EmoNet-Face category order, or input audio [N, 80000] after the loudness step and output scores [N, 2] (valence, arousal). Ship its readout.json, update models.test.mjs if shapes change, and raise VERSION in sw.js if the file name stays the same.
Growing the catalogue. Add entries to music/recordings.json with a freely licensed source and published valence, arousal and tempo, run music/prepare_audio.py, and tag the instruments. Quantile placement and the quality tests adapt automatically, and the limits in 4.8 can then be tightened.
Changing the cost. Every term is in cost(). The path tests and quality limits show whether a change keeps paths gradual and forward.
The same document, with diagrams, is in references/ARCHITECTURE.md. Full references are in RESEARCH.md, DATASETS.md and RELATED_WORK.md.