diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index a5a1678..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore index 21c7a3a..6aa793b 100644 --- a/.gitignore +++ b/.gitignore @@ -76,6 +76,24 @@ Testing/ .claude/ .gemini/ +# Operating-system detritus. +# +# .DS_Store is Finder's per-folder metadata -- icon positions, window size, +# view mode -- written into any directory macOS opens. It is machine-local by +# definition and means nothing on anybody else's disk. One reached the tree in +# 3a1193b; the rest of these are its siblings, listed now rather than after +# each one has had its own turn. +.DS_Store +.AppleDouble +.LSOverride +._* +.Spotlight-V100 +.Trashes +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ + # Editor and Python detritus *~ .vimsupport/ diff --git a/.gitmodules b/.gitmodules index 997c74c..5849626 100644 --- a/.gitmodules +++ b/.gitmodules @@ -18,3 +18,15 @@ [submodule "modules/vorbis"] path = modules/vorbis url = https://github.com/xiph/vorbis.git +[submodule "libs/music"] + path = libs/music + url = https://github.com/chalkwalk/chalkwalk-music.git +[submodule "libs/dsp"] + path = libs/dsp + url = https://github.com/chalkwalk/chalkwalk-dsp.git +[submodule "libs/ninjam"] + path = libs/ninjam + url = https://github.com/chalkwalk/chalkwalk-ninjam.git +[submodule "libs/jambot"] + path = libs/jambot + url = https://github.com/chalkwalk/chalkwalk-jambot.git diff --git a/AGENTS.md b/AGENTS.md index d9573d6..0b9c78e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,10 @@ Authoritative docs (read these before designing anything new): - **`docs/PARITY.md`** -- what has been verified against the reference client, with the measured numbers. - **`docs/ACCESSIBILITY.md`** -- the accessibility story, honestly. +- **`libs/jambot/docs/BOT-CHAT.md`** -- what the practice room's bots would say + and what they would never say. It lives with the bots now; this repository + hosts them and does not design them. + - **`test/README.md`** -- how to run every test layer. Ordering for any new work: **PRINCIPLES -> DESIGN -> ROADMAP**. If a proposal @@ -58,7 +62,33 @@ changes. As of 2026-08-10: ## Layout map ``` -CMakeLists.txt # root: JUCE patching, submodules, src/, test/ +CMakeLists.txt # root: JUCE patching, submodules, libs/, src/, test/ +libs/music/ # SUBMODULE: chalkwalk-music (MIT, JUCE-free). + # github.com/chalkwalk/chalkwalk-music. Euclidean + # lives there now, not in src/. Builds and tests + # standalone; its Catch2 suite runs in our ctest. +libs/dsp/ # SUBMODULE: chalkwalk-dsp (MIT, JUCE-free). Two + # targets: `chalkwalk::dsp` is header-only + # primitives (Svf, PolyBlep, SoftClip, Hermite, + # Denormal) and the plugin links it; + # `chalkwalk::dsp::measure` is the instruments -- + # what `AudioMeasure` used to be -- and carries + # libebur128, so only test and tool targets link + # it. +libs/jambot/ # SUBMODULE: chalkwalk-jambot (MIT, JUCE-free). The + # BAND, and the chat they answer. Was src/jambot/ + # until it earned its own repository; what stays + # here is the hosting a practice room needs and a + # command-line bot does not. Its suite runs in our + # ctest, so we verify the bots rather than assume + # them. Corpora and BotDictionary.h live there + # now, and so do scripts/make_wordlist.py and + # lexicon_gaps.py. +libs/ninjam/ # SUBMODULE: chalkwalk-ninjam (MIT, JUCE-free). The + # wire protocol, and the room conventions in + # RoomConventions.h. Vendors its own ogg/vorbis, + # guarded, so whichever project adds them first + # wins. patches/*.patch # applied to the JUCE submodule at configure time assets/fonts/ # Inter (OFL-1.1), embedded as binary data src/ @@ -78,12 +108,31 @@ src/ Shortcuts.h # Ctrl+Alt shortcut mapping; matches key code, not text AudioDeviceStartup.h # 4-state standalone device-open policy, with a budget ChannelMix.h # mono/pan/gain: one home for three rules that drifted - MusicalKey.h # key and mode: parse, display, scale notes + MusicalKey.h # three inline functions composing the envelope + # (chalkwalk::ninjam::conventions) with the key + # (chalkwalk::music::Notation), under the name + # every call site here already uses + Harmony.h # one line: an alias to chalkwalk::music::Harmony ClipsortLog.{h,cpp} # session archive manifest: read and write StemRender.h # one clip into one interval, resampled and aligned GainUtils.h # dB<->linear, fader and meter scales, formatting IntervalProbe.h # shared test signal: plugin Test Tone and the tests + AudioMeasure.h # one line: an alias to chalkwalk::dsp::measure. + # The instruments moved -- `peak` and `rms` had + # three copies across the ecosystem and + # `fundamentalHz` two, and an uncalibrated + # detector is how measurement error passes for a + # bug. libebur128 went with them. ChatFormat.{h,cpp} # chat rendering: vote lines, chord progressions + RoomHarmony.h # WHICH of the two a chat line is, and nothing else. + # What each MEANS is Harmony::Session in + # chalkwalk-music; how a key travels is + # chalkwalk::ninjam::conventions + # --- the practice room's HOSTING, which stays here --- + PracticeRoom.{h,cpp} # the room: seeds, band settings, the bots in it + PracticeServer.{h,cpp} # a Ninjam server on loopback, so a room needs none + NinjamBotClient.h # that interface over Antiphon's client. The whole of + # what ties the band to this plugin's transport # --- UI --- LocalChannelStrip.{h,cpp} # 90px vertical strip per local input channel RemoteUserStrip.{h,cpp} # card per remote player, channels arranged horizontally @@ -106,9 +155,13 @@ test/ fixtures/testserver.cfg # config for the local ninjamsrv tools/ StemsMain.cpp # antiphon-stems: session archive -> WAV stems + PracticeRoomMain.cpp # antiphon-practice: hosts a practice room; join it + # with the standalone. The only way to meet the + # band today -- nothing in src/ starts a room. scripts/ testserver.sh # fetches, builds and runs a local ninjamsrv out of tree analyze_archive.py # measures a server session archive + trim_soundfont.py # cuts an SF2/SF3 down to the presets we would use docs/references/ # what was read to write this, and at which revision modules/ # ogg, vorbis, clap-juce-extensions submodules ``` @@ -119,6 +172,21 @@ The single source of truth for how this repo is built and tested. First-time clone: `git submodule update --init --recursive` +**Testing a change to a shared library without pushing it.** Point at a working +checkout instead of the submodule; the library's own suite and Antiphon's both +run against it: + +```bash +cmake -B build -DCHALKWALK_MUSIC_DIR=$HOME/Programming/chalkwalk-music +``` + +`CHALKWALK_DSP_DIR`, `CHALKWALK_NINJAM_DIR` and `CHALKWALK_JAMBOT_DIR` likewise, as cache variables or +environment variables. Configure prints `OVERRIDE` when one is in use, because +**the submodule SHA no longer describes what you built** -- so CI must not use +them, and neither should anything meant to be attributable, `docs/PARITY.md` +above all. Iterate with an override; bump the submodule and re-verify before +calling anything done. Same shape as Anvil's `CHALKWALK_PHYSICAL_DIR`. + ```bash # Configure (once, or after CMakeLists changes). No generator flag -- use # whatever CMake picks. @@ -133,8 +201,23 @@ ctest --test-dir build --output-on-failure # count, and the report goes to stdout. Runs headless, no display needed -- # but NOT in CI, where it is excluded on every platform. See ROADMAP.md. ./build/test/AntiphonAudit_artefacts/AntiphonAudit +# Host a practice room and join it with the standalone on the port it prints. +# The band is not reachable from the plugin yet; this is how you hear it. +./build/tools/AntiphonPractice_artefacts/AntiphonPractice --key "D minor" # Offline: turn a session archive into WAV stems. ./build/tools/AntiphonStems_artefacts/AntiphonStems -o stems/ +# Tuning the band's synthesis: render one voice and measure it. The numbers it +# prints come from chalkwalk::dsp::measure, which is what the unit tests assert +# against, so tuning by ear and setting a threshold use one instrument. +./build/tools/AntiphonVoiceLab_artefacts/AntiphonVoiceLab kick --seconds 0.6 +./build/tools/AntiphonVoiceLab_artefacts/AntiphonVoiceLab band --seed 12345 +# Comparing two renders for timbre rather than for level: --lufs normalises to +# an integrated loudness. It warns when a target would clip a sparse voice. +./build/tools/AntiphonVoiceLab_artefacts/AntiphonVoiceLab hat --lufs -27 +# Comparing renders from builds you can no longer reproduce: measure the WAVs, +# and write copies matched to one loudness so the A/B is about the sound. +./build/tools/AntiphonVoiceLab_artefacts/AntiphonVoiceLab file a.wav b.wav +./build/tools/AntiphonVoiceLab_artefacts/AntiphonVoiceLab file a.wav --lufs -20 -o a-matched.wav ``` Targets: `Antiphon_Standalone` (easiest for iteration), `Antiphon_VST3`. CLAP is @@ -147,9 +230,9 @@ is the only thing that compiles it. AU's identity in the plugin registry. JUCE's defaults are the placeholder `'Manu'` and a `string(RANDOM)` plugin code regenerated on every configure, so dropping them would mint a new Audio Unit per build tree and break every saved -Logic session. `Chlk` is shared with arps-euclidya, which uses plugin code -`ArpE`; a new Chalkwalk plugin needs its own plugin code, not its own -manufacturer code. +Logic session. `Chlk` is the shared Chalkwalk manufacturer code; a plugin needs its own +plugin code, not its own manufacturer code. Allocated codes are tracked in the +ecosystem plan. Sanitiser builds -- keep them around, they are worth more than gdb here: @@ -245,6 +328,7 @@ reading past a buffer. Assume your change has the same failure mode. | Mixing, routing, playback delay | `test/AudioLoopbackTests.cpp` | Drives the real path end to end. | | Accessibility naming rules | `test/AccessibilityAuditTests.cpp` | Synthetic node tree; the real UI cannot be compiled into the test target. | | A new control, or a new UI state | `test/AuditMain.cpp` | The `AntiphonAudit` target links the plugin's own library and audits the **real** editor across five states. Add a state when you add a surface -- an unaudited state is how the connect dialog stayed unchecked for its whole life. | +| What a bot understands or says | **`chalkwalk-jambot`, not here** | The bots left. Corpora, suites and the generator scripts went with them; iterate there with `-DCHALKWALK_JAMBOT_DIR=...` and bump the submodule when done. | | Server-visible behaviour | `test/RealServerTests.cpp` | Opt-in via `NINJAM_TEST_SERVER`; keep the default suite hermetic. | ### Rules that are easy to get wrong @@ -257,7 +341,8 @@ reading past a buffer. Assume your change has the same failure mode. (`test/TestSignal.h`). Vorbis is lossy and has codec delay; sample-by-sample comparison against the input will never hold. - **A new `src/*.cpp` must be added to BOTH `src/CMakeLists.txt` and - `test/CMakeLists.txt`.** The test target deliberately re-lists production + `test/CMakeLists.txt`** -- and to `tools/CMakeLists.txt` if a tool uses it, + which is a third list and the one most often forgotten.** The test target deliberately re-lists production sources rather than sharing them -- `juce_generate_juce_header` only works on `juce_add_*` targets, and each target needs its own `JuceHeader.h`. See the comment at the top of `test/CMakeLists.txt`. diff --git a/CMakeLists.txt b/CMakeLists.txt index debb07c..bf27e40 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,6 +7,22 @@ project(Antiphon VERSION 0.1.0) # enable_testing() must be called at the top level for ctest to work enable_testing() +# C++20 -- applies to every target in the tree, including the format-specific +# plugin wrappers (Antiphon_Standalone / _VST3 / _CLAP) that juce_add_plugin +# creates. A per-target PUBLIC cxx_std_20 does NOT reach those wrappers, +# because JUCE compiles their sources independently with its own cxx_std_17 +# floor (see JUCEUtils.cmake). +# +# It is a single standard across the build rather than a preference: JUCE has +# inlines gated on __cpp_char8_t, so a tree that compiles some translation +# units at 17 and some at 20 is an ODR hazard, not merely an inconsistent one. +# Same reasoning and same lines as Anvil and the sequencer. +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS ON) + +include(cmake/JuceSource.cmake) + # Submodule patches, applied at configure time. # # Each entry is "|". Each patch is idempotent: @@ -28,7 +44,13 @@ if(GIT_FOUND) list(GET patch_parts 0 patch_submodule) list(GET patch_parts 1 patch_relative) set(patch_file "${CMAKE_CURRENT_SOURCE_DIR}/${patch_relative}") - set(patch_dir "${CMAKE_CURRENT_SOURCE_DIR}/${patch_submodule}") + # JUCE may be this repository's submodule or the shared checkout that + # CHALKWALK_JUCE_DIR names; everything else is always local. + if(patch_submodule STREQUAL "JUCE") + set(patch_dir "${CHALKWALK_JUCE_ROOT}") + else() + set(patch_dir "${CMAKE_CURRENT_SOURCE_DIR}/${patch_submodule}") + endif() if(NOT EXISTS "${patch_file}") continue() @@ -58,8 +80,7 @@ if(GIT_FOUND) endforeach() endif() -# Configure JUCE -add_subdirectory(JUCE) +add_subdirectory("${CHALKWALK_JUCE_ROOT}" "${CMAKE_BINARY_DIR}/juce-build") # Configure CLAP JUCE extension add_subdirectory(modules/clap-juce-extensions EXCLUDE_FROM_ALL) @@ -70,7 +91,71 @@ set(BUILD_TESTING OFF CACHE BOOL "" FORCE) add_subdirectory(modules/ogg EXCLUDE_FROM_ALL) add_subdirectory(modules/vorbis EXCLUDE_FROM_ALL) -# Add the sources subdirectory +# --------------------------------------------------------------------------- +# chalkwalk-music -- shared, JUCE-free music theory. +# Submodule: https://github.com/chalkwalk/chalkwalk-music (MIT). +# +# It builds and tests standalone, with no JUCE and no parent, which is the test +# of the boundary. Its own Catch2 suite is turned on here so this project +# verifies its dependency rather than assuming it. +# --------------------------------------------------------------------------- +include(cmake/ChalkwalkLibrary.cmake) +chalkwalk_add_library(music libs/music) + +# --------------------------------------------------------------------------- +# chalkwalk-dsp -- shared, JUCE-free DSP primitives. +# Submodule: https://github.com/chalkwalk/chalkwalk-dsp (MIT). +# +# Same arrangement and the same reasoning as chalkwalk-music above. The filter, +# the polyBLEP oscillators, the soft clipper and the Hermite reader lived here +# and in a sibling project, and the two copies had diverged; the shared +# versions take both halves. +# +# It also owns MEASUREMENT now -- `chalkwalk::dsp::measure`, what `AudioMeasure` +# used to be -- and with it the libebur128 dependency that used to be vendored +# in this repository. That is a second target rather than part of the first, so +# the plugin links the primitives without linking a loudness meter; only the +# test and tool targets ask for `measure`. The move was for the usual reason: +# `peak` and `rms` existed three times over across these repositories and +# `fundamentalHz` twice, and a detector nobody has calibrated is how a +# measurement error gets mistaken for a bug (`PRINCIPLES §5`). +# --------------------------------------------------------------------------- +chalkwalk_add_library(dsp libs/dsp) + +# --------------------------------------------------------------------------- +# chalkwalk-ninjam -- the NINJAM wire protocol, JUCE-free. +# Submodule: https://github.com/chalkwalk/chalkwalk-ninjam (MIT). +# +# Added after modules/ogg and modules/vorbis above, and not by accident: this +# library vendors its own copies of both, guarded by `if(NOT TARGET ogg)`, so +# whichever project adds them first wins and the second reuses them. Adding +# them twice is not a version conflict -- it is a duplicate CMake target name, +# which fails the configure outright. +# +# The protocol left this repository under MIT while antiphon stays GPLv3. The +# provenance note in its README is the record of why that is defensible: the +# GPLv2 reference sources were read, never vendored, and never entered any +# published history. See PRINCIPLES.md section 6. +# --------------------------------------------------------------------------- +chalkwalk_add_library(ninjam libs/ninjam) + +# --------------------------------------------------------------------------- +# chalkwalk-jambot -- the practice room's band, and the chat they answer. +# Submodule: https://github.com/chalkwalk/chalkwalk-jambot (MIT). +# +# These were `src/jambot/` here until they earned their own repository. What +# stays is the HOSTING a practice room needs and a command-line bot does not: +# the loopback server, the room that puts a server and a band together, and +# `NinjamBotClient`, which is the whole of what ties the band to this plugin's +# transport. +# +# The direction of the dependency is the point. Antiphon uses the bots; the +# bots know nothing about Antiphon, and their suite runs without it -- which is +# what the `jambot-boundary` check was guarding towards and why that check is +# gone. A boundary a build enforces does not need a test to describe it. +# --------------------------------------------------------------------------- +chalkwalk_add_library(jambot libs/jambot) + add_subdirectory(src) # Offline tools. Kept out of src/ because nothing here is part of the plugin -- diff --git a/DESIGN.md b/DESIGN.md index 24d3291..f4f3292 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -548,6 +548,190 @@ meaningless when there is no server to stop sending. --- +## 6.3 Harmony: what the room is playing over + +Ninjam has no field for a key and none for a chord chart. Both ride on chat, as +text any client shows and this one reads (`PRINCIPLES §10`) -- `[key: D minor]` +and the Jamtaba-style `| Dm7 | C# Csus |`. `src/Harmony.{h,cpp}` and +`src/MusicalKey.{h,cpp}` hold all of it, JUCE-light so the whole layer is +testable headless, and the UI reads the same functions the bots do rather than +computing harmony a second way (`PRINCIPLES §8`). + +**A chord is an absolute root plus explicit tones**, never a scale degree, so a +borrowed or altered chord is expressible without a new model. The vocabulary is +what players write -- `sus4`, `6`, `add9`, ninths and thirteenths, slash basses, +parenthesised alterations -- and wider than the band can voice: five tones is +the ceiling, so a thirteenth keeps its name, its seventh and its thirteenth and +loses the rungs between. Parsing more than we voice is deliberate. A chart is a +document as well as an instruction, and a chord we refuse to read is a chord the +room cannot talk about. + +**A chart keeps its bars.** `| Dm7 | C# Csus |` is two bars, the second holding +two chords, and reading it as three chords evenly spread gives 3+3+2 beats of an +eight-beat interval where the notation says 4+2+2. `Harmony::Chart` is a list of +`Bar`s; `layoutChart` resolves one onto the interval by applying the Euclidean +generator twice -- bars over the interval, then each bar's chords over its own +beats -- and hands back a `Layout`, a table of which chord sounds at every +eighth. Every voice reads that table. Four places used to re-derive the timing +independently, which was tolerable only while chords were evenly spaced. + +At one chord per bar the layout is arithmetically what it was before bars +existed, and that is asserted across bpi 1-16 rather than assumed: it is the +test that says every existing recording of the band still sounds the same. + +**Voicing is chosen, not stacked.** `voiceLead` picks an inversion and octave +per chord to minimise total movement, inside G3-G5. It solves the CYCLE, not the +line: a chart repeats every interval, so the last chord's move back to the first +is the seam a listener hears every time round and is costed like any other move. +Root position everywhere -- what this replaced -- moves all three voices from C +to Am when two of them are the same note. + +**A chart is evidence about the key.** `inferKey` scores every candidate by how +its chord tones sit against the scale, weighted by how much each tone +discriminates rather than by how important it sounds: a fifth is in the scale for +six of seven degrees and rules almost nothing out, where the third separates +major from minor. Content alone cannot separate a key from its relative, so +opening on the tonic, resolving onto it, and a major chord on the fifth degree +break the tie. The result is offered, never applied -- and below a calibrated +margin it says nothing at all, because `| Am | F | C | G |` genuinely is +ambiguous and a suggestion that is wrong half the time is worse than none. + +**Degrees never travel.** `| I | vi IV |` and `| 1 | 4 | b6 |` are resolved +against the session key by the client that typed them, which sends the absolute +chart. Bots, Jamtaba users and anything else in the room see chords they already +understand, and there is one place the resolution can be wrong rather than one +per client. Roman numerals are chromatic and mechanical in both directions -- +`III7`, `bVI`, `#ivo` -- because naming a chord by its function is a claim about +intent where naming it by position is not. + +--- + +## 6.4 Changing the key, and what a chart is relative to + +**Built, except for one UI affordance.** `Harmony::RelativeChord`, +`toRelative` and `resolve` carry the model; `PracticeBot` moves a chart +somebody wrote rather than replacing it with `Harmony::defaultChart`, and +`Harmony::spellNote` supplies the display half. What is left is the chip that +offers a transpose when the letters in a chart were typed rather than derived +(`ROADMAP.md`). + +### A key change is two operations + +Treating it as one is what makes the naive answer wrong. + +- **The tonic moves** (C major -> D major). Pure transposition. Every chord + shifts by the same interval and nothing else changes. +- **The mode changes** (C major -> C minor). The tonal centre does not move at + all. Functions survive; the pitches and qualities of diatonic chords do not. +- **Both** (C major -> A minor) is the composition of the two. + +Transposing semitones alone can only ever preserve interval-from-tonic, so +`I` stays major and the tonality has not actually changed. That is the failure +this model exists to avoid. + +### The rule: preserve what was written, re-derive what was delegated + +A roman numeral is partly a **delegation**. An unaltered numeral whose quality +matches the mode hands the decision to the key. An accidental, or a quality +that contradicts the mode, is the writer overriding the key -- and an override +survives a key change untouched. Letters are the maximal override. + +So each chord in a relative chart carries a binding, decided once when it is +read: + +- **Delegated** -- diatonic to the key it was written in, with the quality the + mode gives. Re-derived from the degree in the new key: `I` -> `i`, `IV` -> + `iv`, `vi` -> `VI`. +- **Overridden** -- anything else. Kept at its interval above the tonic with + its explicit tones, and transposed rigidly. + +### Delegation points at the mode's harmonic realisation, not the raw scale + +`| I | IV | V |` moved from C major to C minor gives `i iv V`, not `i iv v`. +Minor-ish modes define the fifth degree as a major triad, because harmonic +minor exists for exactly that reason and a minor `v` is not what anybody means +by a dominant. + +This is not an exception bolted onto the rule -- it is the library being +explicit that "diatonic in a minor mode" is a convention rather than a scale +readout. `defaultDegreeLoop` already makes the same judgement in the same +place, choosing `i-VI-III-VII` for minor-ish modes rather than mechanically +transposing `I-V-vi-IV`. Somebody who genuinely wants the natural-minor chord +writes `v`, which now contradicts the table, becomes an override, and is +preserved. Both readings stay expressible, which is the test of the model. + +### Accidentals are measured against the parallel major + +`bIII` means three semitones above the tonic, in every mode, always. Measuring +the accidental against the *current* mode makes it meaningless wherever the +scale has no room for it -- `bIII` in natural minor would be a doubly-flattened +third, and in Phrygian `bII` is diatonic while an unaltered `II` is the +chromatic one. + +Storage is therefore an interval, and **spelling is a display concern**: +the same chord is written `bIII` in a major key and `III` in a minor one, and +`| bIII |` typed in a minor key is accepted and echoed back as `III`. + +### Intent is unrecoverable for chromatic chords, and it does not matter + +`bII` in C is Db under at least two incompatible readings: a Neapolitan, which +is a predominant, and a tritone substitution of V, which is a dominant. Nothing +in the text says which, and no analysis recovers it. + +It does not need to be recovered, because **the competing readings agree on +every outcome**. Move to D major and both give Eb; move to C minor and both +stay Db. A chromatic chord is anchored to the tonic the same way whatever it is +called, so preserving the interval satisfies every candidate intent at once. +The same holds for a tritone substitution of a secondary dominant, which lands +at a different interval and is preserved bodily along with it. + +The one place readings genuinely diverge is an unaltered diatonic numeral -- +`V` in C major is both "the chord on the fifth degree" and "G major", identical +until the mode changes. That is the whole of the irreducible ambiguity, and the +delegation rule above is the answer to it. + +### Where intent is unknowable, offer rather than infer + +`bVI` in C major is borrowed colour; the same pitch in C minor is unremarkable. +Preserving it is least surprising -- the chord sounds the same -- but it is not +necessarily what was meant, and nothing recovers that. + +So the transform is applied and the other reading is offered on the chip, the +way an inferred key already is (section 10.1): inferred, shown, never applied +by itself. A **letter** chart is likewise never rewritten by a key change, but +a key change with one up offers to transpose it. This converts an unknowable +into the player's decision, which is where it belongs. + +### Worked examples + +| Written in | Chart | Moved to | Result | Why | +|---|---|---|---|---| +| C major | `\| I \| vi \| IV \| V \|` | A minor | `\| Am \| F \| Dm \| E \|` | all delegated; `V` major by the minor-mode table | +| C major | `\| I \| bVII \| IV \|` | C minor | `\| Cm \| Bb \| Fm \|` | `bVII` was an override, survives; now spelled `VII` | +| C major | `\| I \| V \|` | D major | `\| D \| A \|` | tonic move only, nothing re-derived | +| C major | `\| Dm \| G7 \| C \|` | any | unchanged | letters are absolute; a chip offers the transpose | + +### The edge this shares with non-diatonic harmony + +The *Harmony beyond diatonic* work area -- secondary and altered dominants, +tritone substitution, borrowing -- rewrites the same layer. A bare degree +cannot express a tritone substitution, which is why `Chord` carries an absolute +root at all. The relative representation must therefore be **richer than a +degree and poorer than a chord**: an interval from the tonic, explicit tones, +and the binding above. Get that type right and both features fit in +`Harmony::realise`; get it wrong and they fight. + +### How it is tested + +Pure, JUCE-free, and table-driven, like the corpora: rows of *(chart, from-key, +to-key, expected chart)*, with the interesting rows being the arguments above -- +`I IV V` major to minor, `bVII` major to minor where an override becomes +diatonic, `bII` under both readings, `v` as a deliberate override, and a letter +chart asserted unchanged. Disagreement later is then an edit to a table rather +than a rereading of the code. + +--- + ## 7. Remote playback, mixing and routing Each `(username, channelIndex)` pair holds one of the fixed `streamSlots` @@ -758,6 +942,24 @@ Per `PRINCIPLES §12`, state is announced by colour and motion before text. when idle. The amber clears when Connect is clicked again. - **Phase bar** advances only when in sync. Teal when connected, grey when not. Beat ticks and flashes are suppressed when disconnected; phase resets to 0. +- **Chord timeline**, when a chart has been announced: a row of chord names + directly above the phase bar, each at the position in the interval where its + change falls, so the fill sweeps through them and the chord now sounding is + the bright one. Position carries the timing, which is why it is not a line of + text elsewhere. The header grows 80 -> 96 px only while it is showing. The + same chart in roman numerals sits at the right end of row 2, where what it + carries is the shape of the progression rather than when anything happens. + Only an announced chart is ever drawn: a progression the room did not agree + to would be a lie on screen. +- **Chip precedence**, in the one row between the chat and its input: a live + server vote first, because it is a decision already in progress; then a key + the chords imply but nobody has declared; then the DAW tempo worth proposing. + Every one of them is an offer -- the chip never acts on its own, and accepting + the key suggestion sends exactly the message `/key` sends. +- **The spoken status carries the key and the chart** because the drawn one + does. The chord *sounding* is deliberately not in it: it changes several times + a bar, and reading state that moves on a timer is what `PRINCIPLES §11` + refuses. - **Chat panel** is ghosted (disabled, near-black, dim text, "(not connected)" placeholder) when disconnected, and cleared on the next successful connect so a new session does not open with the last one's backlog. diff --git a/README.md b/README.md index 4042ac2..533b4db 100644 --- a/README.md +++ b/README.md @@ -56,13 +56,15 @@ centre, chat on the right](docs/images/antiphon.png) |---|---| | **Works** | Connecting, transmitting, receiving, multi-channel, stem routing, chat, voting, the metronome, DAW tempo sync | | **Verified** | Interoperability with the official NINJAM reference client, measured -- interval grid, transmit alignment, audio in both directions, chat. See [`docs/PARITY.md`](docs/PARITY.md) | -| **Used on** | Linux, CLAP format, one DAW, plus the standalone | +| **Used on** | Linux, CLAP format, one DAW, plus the standalone. Once on macOS, as an AU: built, loaded in a host and used to join a jam, by a contributor on their own machine | | **Builds on** | Linux, macOS and Windows -- all three compile and pass the full unit suite in CI, with no platform-specific source | -| **Not yet** | Loaded in a host on macOS or Windows: nothing there has opened a window, opened a device or joined a jam. The AU build is newer still -- it has never been compiled on this machine, which is Linux, so CI is the first thing to build it and no host has seen it. No packaged installers, no release | +| **Not yet** | Loaded in a host on Windows: nothing there has opened a window, opened a device or joined a jam. macOS has been through that path exactly once, by hand -- it is not regularly tested and nothing automated instantiates the plugin on any platform. No packaged installers, no release | -If you are on Linux and comfortable with CMake, it works today. macOS and -Windows build and test clean, but "compiles and passes its tests" is not the -same as "works in your DAW", and nobody has checked the second thing yet. If you +If you are on Linux and comfortable with CMake, it works today. macOS has been +run for real once, including with a screen reader, and the report was good -- but +once is once, and it is not part of any automated check, so treat it as +encouraging rather than as a guarantee. Windows builds and tests clean, and +"compiles and passes its tests" is not the same as "works in your DAW". If you are waiting for a download, that is [on the roadmap](ROADMAP.md). --- @@ -373,6 +375,9 @@ ghosts out when you are not connected, and clears when you join a new session. | `hello` | Says hello to the room | | `/me plays a wrong note` | Third-person message | | `/msg bob you there?` | Private message to bob | +| `/key Dm` | Tells the room the key (any client can type `/key D minor` at the start of a line) | +| `/chords Am F C G` | Tells the room the chords | +| `/chords ii V I` | The same, in degrees, once a key is set | | `/topic Jam in D minor` | Sets the room topic | | `!vote bpm 130` | Proposes a tempo change | | `!vote bpi 8` | Proposes an interval length change | @@ -382,6 +387,20 @@ Votes need a majority of the room. When one passes, the tempo changes for everyone at the next interval -- **including you**, so in a DAW you will need to change your project tempo again and re-Sync. +Ninjam has no protocol field for a key or a chart, so both ride on chat as text +every other client shows plainly. A chart appears above the phase bar with each +chord where its change falls, so you can see the next one coming; the roman +numerals sit at the right of the row above. `| Dm7 | C# Csus |` is two bars, and +the second holds two chords, so Dm7 lasts twice as long as either of them. + +Degrees are turned into chords by your own client before anything is sent, so +`/chords ii V I` leaves as `| Dm7 | G7 | Cmaj7 |` and everyone else in the room +sees chords they already understand. Each chord is spelled against the key +rather than the whole chart being spelled one way: D major takes sharps, and a +flattened second in it is still `Eb7`. If a chart makes the key obvious and nobody +has set one, Antiphon offers it on the chip under the chat -- and stays quiet +when the chords are genuinely ambiguous. + --- ## Accessibility diff --git a/ROADMAP.md b/ROADMAP.md index 5216151..7ac4608 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,28 +13,72 @@ For architecture see `DESIGN.md`. For the principles every piece of work must satisfy, see `PRINCIPLES.md`, and for the standing refusals `NON-GOALS.md`. **Before adding a work area here, confirm it clears both.** +**Shared code across the Chalkwalk plugins is planned separately**, in a +document kept outside this repository -- which libraries are extracted, which +third-party dependencies are taken, and the licence and JUCE-free rules. That +argument is not restated here, and nothing below depends on having read it. + --- ## Active focus -*(2026-08-08)* +*(2026-08-15)* + +The client works, and it has now been used by somebody other than its author. +It connects to real servers, transmits and receives in time with other clients, +and is verified differentially against the reference client at two tempos +(`docs/PARITY.md`). A contributor has built the AU on macOS, joined a jam and +worked it with a screen reader, reporting that it compares favourably with the +official client -- the first evidence the accessibility pass works where it +counts. Bounded honestly under *Screen-reader verification*. -The client works: it connects to real servers, transmits and receives audio in -time with other clients, and has been verified differentially against the -reference client at two tempos (`docs/PARITY.md`). The accessibility pass has -just landed -- every control is named, the header is readable, and the audit runs -headlessly. +The three items this block listed on 2026-08-08 have all landed: audio-thread +hygiene is down to the one tracked `callAsync`, the GitHub move shipped, and all +three platforms build and pass the unit suite. The work since has been the +practice room and its band. Next, in order: -1. **Audio-thread hygiene** -- two known real-time violations, both easy, both - the kind of thing that becomes a dropout report from a user we cannot debug - for. -2. **The GitHub move** -- `LICENSE`, `CONTRIBUTING.md` and a README that stands - on its own, since the project is going public. -3. **Cross-platform builds** -- everything so far is Linux and CLAP. The - accessibility work in particular is *only* effective on the two platforms we - have never built for. +1. **Bots that talk** -- specifically, wiring `BotLanguage` and `BotAnswer` into + `PracticeBot`. Both are finished and measured (99.3% on a held-out corpus) + and **neither has a caller**, so none of that accuracy is reachable by a + player. The largest gap in the project between what is built and what can be + used, and the cheapest to close. +2. **Turn the macOS report into findings** -- a verdict cannot be acted on. Ask + for the specifics while they are fresh, and decide what stops the AU + regressing silently, `auval` in CI being the cheap first answer. +3. **Windows in a host** -- the remaining platform where nothing has opened a + window, opened a device or joined a jam, and half of where the + screen-reader work is reachable at all. + +*(This block predates the ecosystem work described immediately below and has +not been re-derived since; item 1 in particular is now largely built -- +`BotChat` wires `BotLanguage` and `BotAnswer` into `PracticeBot`. Treat the +ordering as stale until it is refreshed.)* + +**What has landed since, 2026-08-18/19: the repository split, and it went the +other way round.** That plan is complete. This project consumes +[chalkwalk-music](https://github.com/chalkwalk/chalkwalk-music), +[chalkwalk-dsp](https://github.com/chalkwalk/chalkwalk-dsp) and +[chalkwalk-ninjam](https://github.com/chalkwalk/chalkwalk-ninjam) as submodules +under `libs/`, all MIT, all JUCE-free, each building and testing standalone. + +The entry above argued against splitting *while the practice room is +unreachable*, and that argument stands and was not overruled -- what changed is +the unit. Nothing was restructured around the practice room: what left were +four pieces of general-purpose code (music theory, DSP primitives, the wire +protocol, the loudness meter) that this repository happened to hold, and the +plugin's own shape is untouched. The **client** stayed here for exactly the +reason this entry gives; only the **protocol** left. See *Split the client +out*. + +Also adopted: **libebur128** for ITU-R BS.1770 loudness, replacing 107 correct +lines of K-weighting and gating in `AudioMeasure.h`. Not a bug fix -- the two +agree to under 0.001 LU on gated material, which is how the swap was checked. +The finding was about the tests: the five ffmpeg goldens are steady sines, +where every block holds equal energy and the gate never decides anything, so +they could not tell the two implementations apart and the relative gate had no +coverage at all. It has now. --- @@ -378,12 +422,34 @@ an actual screen reader. The mechanical audit cannot judge whether a description is helpful, whether the tab order feels sane, or whether announcements land at useful moments. -- [ ] Test with VoiceOver on macOS and NVDA on Windows -- the two platforms where - JUCE actually has a backend. -- [ ] Get a session with a screen reader user, which is the only thing that - answers the questions the audit cannot. +**First external result, macOS, from a screen reader user.** A contributor built +the AU on their own Mac, loaded it in a host, joined a jam and worked the plugin +with a screen reader. Their assessment was that it compares *favourably* with the +official client, and that they intend to recommend it to blind musicians they +play with. This is the first evidence the accessibility pass works where it +counts rather than where it is measured, and the audit alone could never have +produced it. + +Hold it at what it is, though: **one session, one platform, one person, recorded +from a verbal report rather than from notes.** It is strong evidence the approach +is right and weak evidence about any specific control. Nothing here is a +substitute for the checkboxes below, and the standing rule (`PRINCIPLES §5`) +applies -- a favourable result gets the same scrutiny as an unfavourable one. + +- [x] Test with VoiceOver on macOS. Done once, by hand, as above. +- [ ] **Capture what was actually found.** The report was a verdict, not a list. + Ask for the specifics while they are still fresh: which controls read + badly, where the tab order surprised them, whether announcements arrived + at useful moments, and what they reached for that was not there. A verdict + cannot be turned into a fix; a list can. +- [ ] Test with NVDA on Windows -- the other platform where JUCE has a backend, + and still wholly unexercised. +- [ ] Repeat sessions rather than one. The questions the audit cannot answer are + not answered once either, and the people best placed to answer them are + now reachable. - [ ] Assess the standalone's audio-device picker (stock - `AudioDeviceSelectorComponent`, never looked at). + `AudioDeviceSelectorComponent`, never looked at). Not covered by the + session above, which came in through the AU. ### Chat history structure @@ -400,6 +466,963 @@ announcing them continuously -- which `PRINCIPLES §11` explicitly refuses. A deliberate "read me the levels now" gesture is the missing half of that decision. - [ ] A shortcut that speaks the current levels once, on demand. +- [ ] The same gesture, or one beside it, for the harmony: the key, the chart, + and the chord sounding now. The chord changes several times a bar, so it + can never be announced on a timer -- which is exactly the argument above, + and the reason it is the same work area. + +### Melodic shaping: the two terms held back + +The lead now prices the interval it moves by (`chalkwalk::music::chooseNote`), +which is what stopped it leaping oddly. Two further terms were designed at the +same time and deliberately **not** shipped with it, so each can be heard on its +own rather than as part of one large change to how the melody sounds. + +**Direction memory.** After moving up, moving down again should cost a little, +and vice versa -- so a run reads as intentional rather than as a sequence of +independent decisions. The classical rule is the opposite (reverse after a +leap, to fill the gap), and both are right at different sizes. One term +captures both, with the sign set by how big the previous move was: + +``` +directionCost = same direction as the last move ? 0 : reversalCost + reversalCost = +2 when |lastMove| <= 4 -- continuing a run reads as intent + = -3 when |lastMove| >= 7 -- a leap wants filling in +``` + +It must stay small relative to the contour weight, because the contour +(Rise/Fall/Arch/Walk) is already doing directional work and a strong direction +term will fight it. `leadstats` reports "direction kept", which is the number +to watch: it sits near 58% today. + +**Rest and duration by strength.** Duration in `renderLead` is *emergent*, not +chosen -- a note is held until the next sounding step -- so "spend longer on +strong notes" and "rest after strong notes" are the same lever, not two. The +place to pull it is the existing dropout rule: + +```cpp +// was: if (strength == 0 && rng.range(0, 2) == 0) continue; +if (strength == 0 && rng.range(0, 5) < 3 - tierOfPrevious / 2) continue; +``` + +Rest more readily after a strong note, and the strong note is held longer for +free. + +**The coupling question, answered: one way only.** Note choice may inform the +rhythm; the rhythm must never depend on it. The onset grid is the Euclidean +figure the rest of the band shares, and a lead whose figure moved with its note +choice would stop playing the same groove as everyone else. That invariant is +asserted -- `LeadLineTests` checks that every sounding step is an onset of the +lead's own figure -- and it must survive this change. + +There is a second, independent duration lever that touches no rhythm at all: +the colour-note cap in `renderLead` (`held = min(length, eighth)` for tier 2) +generalised to `capForTier()`. It shortens weak notes with a note-off rather +than by moving a note-on, so it composes with the rest bias instead of +competing with it. + +- [ ] Direction memory, measured with `leadstats --repeats 40`, heard before + and after. +- [ ] Strength-biased rests, with the figure invariant still asserted. +- [ ] `capForTier()` in `renderLead`, replacing the hard-coded tier-2 cap. + +### Voicing by register, not by pitch class + +The band's lead avoids a semitone above a sounding chord tone +(`noteTier` in `BotBand.cpp`), and that rule is **register-blind**: it +compares pitch classes, so `B4`/`C5` and `B6`/`C7` are the same question to +it. They are not the same answer. A semitone that is unusable in a close +mid-register voicing is playable two octaves up. + +That matters here more than anywhere else in the ecosystem, because the band +puts its chords below its lead by design ("an octave above the keys, so it is +heard as a melody over the chords rather than as part of them"). The chords sit +in the muddy register and the lead in the clean one, so the same pitch class is +a mistake in one octave and fine in another -- and the current model can only +veto it, never move it. + +The theory, the measurements and the interface change belong to +chalkwalk-music and are written up in its +[ROADMAP](https://github.com/chalkwalk/chalkwalk-music/blob/main/ROADMAP.md). +The short version: roughness depends on how many CRITICAL BANDS an interval +spans, pitch is logarithmic and the critical band is not, so the same interval +is a different amount of rough depending where it is played. Thirds and wider +clean up monotonically as they rise; the semitone does not, and is roughest +around C4-C5 -- which is exactly the register the band's keys occupy. + +- [ ] Wait for chalkwalk-music to grow a register-aware rank. This is not + antiphon's to solve; it is one model and it should have one home. +- [ ] When it lands, the lead's clash rule becomes a VOICING decision rather + than a veto: a colour note that clashes below can be taken an octave up + instead of being dropped. +- [ ] Re-check the band's register split afterwards. The lead sits at 72 and + the keys below it because of a rule that will have changed. + +### The band's harmony + +The practice room's band plays over a chart, and a chart is also the one thing +a room can say about its music that Ninjam has no field for. Both halves live in +`src/Harmony.{h,cpp}`; see `DESIGN.md` section 6.3. + +- [x] Chord vocabulary players actually write, and a name for every chord read. +- [x] Bars survive parsing, so a bar holding two chords is half the time each. +- [x] One layout table per interval, shared by every voice. +- [x] Voice leading for the keys bot, solved around the loop. +- [x] A key inferred from a chart, offered on a chip and never applied by itself. +- [x] Degrees and roman numerals, resolved locally so nothing new goes on the wire. +- [x] The chart drawn along the phase bar, where position carries the timing. +- [ ] **Chart repetition.** `| ii | V | I |` might be a three-bar loop or the + same loop three times over a long interval. Today a chart always fills + exactly one interval. A repeat count -- explicit, or inferred when the bars + divide the interval evenly -- is its own decision. +- [ ] **A key change keeps the chart, and the chart says what it is relative + to.** Designed in `DESIGN.md` section 6.4; that section is the + specification and this is the checklist. The bug underneath it -- a key + announcement calling `Harmony::defaultChart` and throwing away a + progression somebody typed -- is fixed; what is left is one UI affordance. + - [x] A relative chord: interval from the tonic, explicit tones, and a + binding of delegated or overridden. `Harmony::RelativeChord`. + - [x] Decide the binding when the chart is read: diatonic with the mode's + quality is delegated, anything else is an override. `toRelative` + and `resolve`, with round-tripping in one key asserted lossless + over seven charts and five keys. + - [x] A minor-mode realisation table, so a delegated `V` stays major. + `Harmony::modeChordOn`. A slash bass is never delegated: an + inversion is a voicing decision the key has no opinion on. + - [x] Spelling derived per chord: `Harmony::spellNote`, and `chartText` + and `chordName` overloads that take the key rather than a flag. In + the scale the key has already decided; out of it, a lowered degree + from above and a sharp at the tritone, by the same rule + `romanName` uses so a chart and its numerals cannot disagree. + - [x] Accidentals measured against the parallel major in + `RelativeChord::semitones`; display reads from the mode's own + scale, so `bIII` in a minor key echoes back as `III`. + - [x] `parseDegreeChart` reachable from the practice room, read against + the key the room is already in -- and from the CLIENT too, via + `src/RoomHarmony.h`, which is the one place a chat line's effect on + the key and the chart is decided. It was two places and they + drifted: the band followed `| ii | V | I |` and carried a chart + through a key change while the chord row above the phase bar did + neither, so the display went stale with nothing to say so. + - [x] **A key change no longer bins the chart.** `PracticeBot` moves a + chart somebody wrote through `toRelative`/`resolve`, and rebuilds + only a chart the key itself implied. This was the bug underneath + the whole section. + - [x] "Use the default chords for this key" as something a player can ask + for: `RESET_CHART`, 24 corpus lines, answered by + `BotAnswer::answerResetChart`. Offers the line to paste rather than + acting -- a bot that reverted its own chart would be playing + something nobody else in the room could see. + - [x] The fixture table: `(chart, from-key, to-key, expected chart)`, + with the arguments from section 6.4 as its rows, in + `HarmonyTests`. + - [ ] Letters never rewritten; a key change with one up offers the + transpose on the chip instead, the way an inferred key is offered. + The only piece left, and it is UI: the editor renumbers and + respells on a key change but has never re-derived, so nothing is + wrong today -- there is just no way to accept the move. +- [ ] **Harmony beyond diatonic.** `Harmony::realise` is the named seam: + secondary and altered dominants, tritone substitution, borrowing from + adjacent modes. Functional roman naming (`V7/vi`) belongs with it, since + it is the same knowledge and today's naming is deliberately mechanical. +- [ ] **Fuller voicings.** Ninths and thirteenths voiced rather than named only, + and dropping the root from the pad when the bass is already on it. +- [ ] The practice room is not wired into the processor at all yet, so the + timeline's "show the band's own chart in practice" rule is written but + unreachable. It lands with the room. + +### Bots that talk + +Practice is the best introduction to Antiphon and nothing says so. Beyond +teaching, the bots could feel like present players rather than pattern +generators -- answering when asked what they are playing, noticing a chart they +cannot read -- without a language model and without becoming a novelty. + +**Designed in `libs/jambot/docs/BOT-CHAT.md`; that document is the proposal and this is the +checklist.** Chat only: the bots do not listen, and musical interaction is +separate future work. What makes a bot feel alive here is precision and +restraint rather than conversation. + +- [x] `src/BotLanguage.{h,cpp}`: a cascaded finite-state recogniser -- segment + clauses, fuse idioms, decide word class from context, map to concepts, + repair what is not a real word, read the clause's force, score with a + margin. Indirect phrasing has to work or the bots feel like a vending + machine. +- [x] A corpus of phrasings and their intents, including the ones that must be + clarified rather than guessed and the ones that must not be answered at + all: `test/fixtures/bot-phrases.txt`, 607 lines, **a quarter of them held + out from tuning**. The three miss rates over the holdout are the numbers + to quote and drive down. +- [x] **They have been driven down, and this axis is finished.** Measured + 2026-08-15 by `NinjamTests BotLanguage`: tune 467/469 correct (99.6%), + **holdout 147/148 (99.3%)** -- fallback 0.0%, clarify 0.0%, wrong 0.7%, + which is a single held-out case. `BotAddress` is 143/143 over its own + corpus and `BotAnswer` passes 74 assertions. Further tuning would be + fitting to noise; the remaining work on this feature is *connection*, not + accuracy. Re-run the suites rather than citing these numbers second-hand + (`PRINCIPLES §5`). +- [ ] **Measure the server's vote threshold.** `libs/jambot/docs/BOT-CHAT.md` proposes how + the band votes, and the whole proposal rests on `M` as a function of the + number of clients -- which nothing here records. Connect a varying number + of clients to `scripts/testserver.sh` and read it off the vote line before + building any of it (`PRINCIPLES` §5). +- [ ] **The band's vote policy.** Bots are ordinary clients, so they count + toward the threshold, and abstaining is a vote against: four of them take + tempo control away from a room of three humans entirely. The rule -- vote + only for a candidate a majority of humans already back, never propose one, + staggered like the arrival roster -- is designed in `libs/jambot/docs/BOT-CHAT.md` and needs + no coordination between the bots: they queue behind staggered delays the + way they announce themselves, and each checks on waking whether the motion + already carried, so the band casts exactly the shortfall and stops. + Nothing casts a vote today. +- [x] `src/BotAnswer.{h,cpp}`: what a bot says when asked about the room, as + pure functions over a `Room` struct, with key and chart provenance + (defaulted / topic / chat). Every reply is asserted not to parse as a key + announcement or a chart, because saying either performs it. +- [x] A second key form, `/key D minor`, matched only at the start of a line -- + so the key can be explained without being set, and so any client can set + it. `MusicalKey::parseAnnouncement`. +- [ ] **Sync the practice room's topic to the key.** The room owns its server, + so the topic can be derived state and therefore never stale -- but + `PracticeServer` has no chat hook to notice a key change through, and that + plumbing wants designing rather than bolting on. Never on a server we do + not own. +- [ ] Answering `SET_KEY`, `SET_TEMPO` and `SET_CHART` honestly. All three are + recognised; none is a thing a bot may decide, and saying so is the point + of recognising them. Three parts, designed in `libs/jambot/docs/BOT-CHAT.md`: that the + room decides, what it currently is, and how to change it in any client + (`!vote bpm N`, a `| Am | F |` line, a `[key: ...]` tag). Two special + cases, both about not implying a decision was made: a key that was + defaulted rather than chosen, and no chart at all. +- [ ] **The key tag is self-triggering, and reply text must respect it.** + `MusicalKey::parseTagged` matches `[key:` anywhere in a line, so a bot + explaining the syntax would set the key by explaining it. The answer is + that the bot puts the tag up itself rather than teaching it -- a + translator, not an authority, since any player in any client can type the + tag and `/key` is only a shortcut for it. Whatever renders bot chat needs + a test that no reply text parses as a key. +- [ ] **One bot answers a common question.** Addressing decides who was asked, + not how many should speak, and `REPORT_*`/`SET_*` are one fact rather than + four. Acting stays collective -- `band, shake` rerolls all four -- and only + the line about it is rationed. +- [ ] **One arbitration primitive, four uses**: the arrival roster, the tempo + vote, the key-change acknowledgement and common answers. Staggered delay, + then check whether the job is already done. It should replace the fixed + "lowest instrument first" order the key-change cue was designed with, + which picks a bot that may have been told `quiet` and then never speaks. +- [ ] `src/BotChat.{h,cpp}` as pure functions over what a bot knows, so a seed + and a script of events give a byte-identical transcript. +- [ ] A fifth, instrument-less tutor bot that teaches six lines and then parts. + The players play the changes; they do not teach. +- [ ] The tutor's one piece of listening: subscribed to the owner alone, using + `AudioMeasure` plus a duty cycle and a transient count to tell silence, + a faint signal, clicks and clipping from somebody playing -- so it can say + "that went out" rather than hope. It gates which encouraging line is said + and never becomes a judgement. +- [ ] The budget, and a test that asserts a hundred events produce at most N + lines. The test that keeps it from becoming annoying. +- [x] `quiet`, per bot: `SET_QUIET`/`SET_LOUD` reach + `BotChat::Act::SetChatMuted`. The gate is applied once, after the + decision, so a new intent cannot forget it; only two things still speak, + and both confirm an action rather than commenting on one -- coming back, + without which there is no way out of the mute, and leaving. +- [ ] Unprompted speech off outside the practice room. Nothing speaks + unprompted yet, so there is nothing to switch off; it lands with the + tutor. +- [ ] **Being present without playing.** Built, bar two things: the endings have + never been listened to, and nothing outside the practice room can reach + the states. **Designed in `libs/jambot/docs/BOT-CHAT.md` section 15; that section is + the specification and this is the checklist.** + - [x] Four states -- Silent, Playing, Wrapping, Resolving -- sampled ONCE + per interval at the top of the render and held for it. `Wrapping` + and `Resolving` advance on their own, one interval each; `start` + during `Wrapping` cancels the ending, and nothing escapes + `Resolving`. `src/BandPlayState.h`, pure and driven directly by + `test/BandPlayStateTests.cpp` -- through a room the timing is only + observable as several seconds of audio. + - [x] `Silent` transmits NOTHING, rather than an interval of zeroes, and + a silent bot still follows the key and the chart: that is most of + what anybody does between tunes. Band membership (`inBand`) and + audibility are separate questions now. + - [x] `START_PLAYING`/`STOP_PLAYING` reach `BotChat::Act`, and the reply + depends on what the bot is already doing -- four states, four + different truths. Reading again part-way + tears an interval across two states, and delivery is + all-or-nothing. `PracticeBot::playing` already exists for this and + is dead weight today: never cleared, and `BotChat::Self::playing` + is passed in and never read. + - [x] `stop` means stop PLAYING, not leave. It was a part command in + `kPartCommands`, in `BotAddress::isPartCommand` and in the `[LEAVE]` + corpus, which contains `stop playing` in as many words -- the `part` + footgun again, with the least destructive phrase wired to the most + destructive act. Takes `halt`, `enough`, `thats enough` and + `were done` with it; leaving keeps words that can only mean leaving. + - [x] `START_PLAYING` / `STOP_PLAYING` intents, corpus lines first, and + the acts to carry them. Individual and whole-band come free: + `BotAddress::Address::Collective` already sits beside `Named`. + - [x] The ending is TWO intervals, as `BotBand::Phase` through + `renderInterval` rather than a second code path. A complete wrap-up + interval -- same chart, lead laying out at the halfway point, keys + thinning behind it, kit filling through the last bar -- a taper + rather than a switch, since nobody winds down all at once. The BASS + is deliberately unchanged: the rhythm section carries the time into + the final downbeat. Then a resolving interval that opens on the + chord the loop resolves to, rings two beats, and is quiet for the + remainder. A downbeat chord with nothing leading into it is a + dropout with a note on the front; the wrap-up is what makes the + ending sound intended, and it is where the fill lives. + - [x] The wrap-up invents NO harmony -- no turnaround, nothing the room + did not write. The chart is the room's; the signal is arrangement. + - [x] The resolve lands on `Harmony::resolutionChord`: the room's own + tonic chord if the chart contains one, otherwise the mode's tonic + triad. NOT the chart's last chord, which is often the V precisely + so the loop loops. One rule covers blues, modal vamps and plain + diatonic, and it only invents when the chart never said what the + tonic sounds like here. + - [x] Do NOT reach for `inferKey` when the ending sounds wrong in an + unannounced key. Held to: nothing in the ending path consults it. A key guess is offered, never acted on; the wrong + ending is a symptom of an unset key and the fix is to set it. + - [x] It costs nothing extra: the band renders an interval every slot + regardless, so this is two ordinary intervals of CPU and bandwidth. + What is spent is time -- about three intervals from typing to + silence, 12 s at 120/8, which is roughly how long a real band takes + and scales sensibly with bpi. + - [ ] Tune how the two intervals SOUND by ear, in `AntiphonBandLab` or + `AntiphonVoiceLab`. The SHAPE is asserted -- energy on the downbeat + and quiet after, the lead out by the last quarter, a fill present, + each phase distinguishable -- but the numbers behind it have never + been listened to: how long the chord rings, how far the keys thin, + and whether the kit's landing wants more than an open hat over the + kick, which is standing in for a crash the kit does not have. + - [x] The reply says what is about to happen rather than implying it + stops now: "wrapping it up -- ending on the downbeat after this + one." + - [ ] Nothing outside the practice room can start or stop the band yet: + the states are reachable only from chat. + - [x] Arrive Silent. The band connects before the player does, so playing + on connect played to an empty room; the roster line already re-arms + for the first human and is where start/stop is taught -- the way IN + first, because a room where nothing happens looks broken. Disposes + of the wait-forever COST as a side effect: a band nobody joins now + encodes nothing. + - [x] **One authority tier: any human, every command.** Eviction is + already open to everyone deliberately, so gating anything less + destructive behind ownership would be incoherent. The owner is not + a permission -- it is who the cleanup rule watches. Bots still take + no orders from bots. + - [x] Owner departure stops being fatal. A PART used to call `part()` at + once, `onDisconnected` refuses to reconnect by design and + `reapPartedBots` deletes the objects -- so a 30 s blip destroyed the + band and the room ran on empty. Now, on the other-humans predicate + the roster already computed: others present -> keep playing and + start no clock, since the band plays for the room and anyone present + can dismiss it; room empty -> silence plus three minutes; nobody + arrived yet -> six. Silencing CUTS rather than ending, because an + ending played to nobody is encoding for its own sake, and the + departure rule is `BandPlayState::silence`'s only caller. + `PracticeRoom::Config` carries both durations, so the countdown is + testable in seconds rather than minutes. + - [x] Returning inside the window does not restart them, and needs no line + of its own: the arrival roster already re-arms for the first human in + a room, which on a reconnect is the returning player, and says + exactly what a welcome back would. Where it does not re-arm, others + were present and the band never stopped -- so both cases are covered + without a line, which beats having one. +- [x] **One bot speaks for the band.** A collectively addressed message whose + answer would be the same from everyone gets exactly one reply, phrased + for the band ("we're wrapping it up"); one whose answer differs -- what + each is playing, sounds like, is -- gets all four. Delay-and-watch, with + a bot that ACTED speaking ahead of one that had nothing to do, so a + half-stopped band does not have a silent bot answer for it. +- [ ] Addressing: at most one bot ever answers, cold silence is the default, + first contact must be explicit, and a message aimed at a human is + answered by nobody. Four bots replying to one question is the annoyance + the whole feature has to avoid. Corpus at + `test/fixtures/bot-addressing.txt`, 143 cases, many of them "nobody". +- [x] `tools/PracticeRoomMain.cpp` (`antiphon-practice`): hosts a room and waits, + so the band can be heard and talked to before any of it is reachable from + the plugin. Cheap because the room was designed as a destination rather + than a mode -- there was nothing to integrate, only something to start. +- [ ] **Chat entry affordances**: cursor up/down through sent-message history, + and tab completion of usernames. Addressing a bot means typing its name, + so completion is not a convenience here -- it is most of the friction. +- [x] **Wire the addressing half.** `BotAddress::classify` is called from + `PracticeBot::handleAddressed` (`src/PracticeBot.cpp:637`), so *who was + asked* is decided by the measured recogniser. `withoutAddress` strips the + name before command matching, which is what stopped "Ravo: shake" + defeating every command. +- [x] **Wire the other half.** `src/BotChat.{h,cpp}` is the join: a pure + function from (room, music, self, message) to *what to say and what to + do*, with `PracticeBot` reduced to a snapshot in and an intention out. + The ad-hoc exact matching it replaced (`handlePrivateCommand`, + `handleBandCommand`) is gone. Covered by `test/BotChatTests.cpp`, which + is where the words are asserted without a socket. +- [x] **The trap in that wiring.** A reply quoting `[key:` would set the key by + explaining it. `BotAnswer` asserts it over its own replies, and the sweep + runs over every provenance combination -- both `keySource` and + `chartSource`, since varying only one leaves `describeChart` unable to + return the bare chart text that is the actual hazard. +- [x] **`PracticeBot` has a test file of its own**, which the client interface + is what made possible: a thirty-line fake client, no socket, no room, and + the answers arrive synchronously. It found a real gap immediately -- a + parted bot went on answering, because the guard had always been the + transport's rather than the bot's. + +### A legal BPI can exhaust memory + +`NinjamClient` reserves one decoded interval per remote channel at +`sampleRate * 60 / bpm * bpi * 1.5`. That is 2.3 MB per channel at the usual +120/8, and the server will happily go far past it -- **1000 BPI and 39 BPM are +both legal and both were set on a live server by accident** (measured; see +`docs/references/ninjam.md`). + +| BPI | BPM | Reserved per remote channel, per interval | +|---|---|---| +| 8 | 120 | 2.3 MB | +| 64 | 120 | 18.4 MB | +| 1000 | 120 | **288 MB** | +| 1024 | 40 | **885 MB** | + +A room at 1024/40 with four remote players asks for three and a half gigabytes, +allocated on the network thread, with no guard anywhere. + +- [ ] Decide what a client should DO about an interval it cannot afford. The + options are all unpleasant -- refuse to connect, connect muted with an + explanation, or cap and accept that playback is wrong -- and the honest + one is probably to say so in the UI rather than to fail silently. +- [ ] Whatever is chosen, **do not clamp the tempo we display**. JamTaba drops + out-of-range config with no `else` and shows a stale tempo instead + (`ServerInfo.cpp:112-134`); at 1000 BPI it desyncs outright, showing 8 in + its selector and 32 on its metronome while the server is at 1000. Being + wrong quietly is worse than being unable to play. +- [ ] Consider warning before `/bpi` sets something the room cannot follow. The + server allows it, but no other client in the room will survive it. + +### Form: repetition, tension and release + +The parts are generated fresh every interval and never return to anything, so a +long session meanders: nothing recurs, nothing builds, nothing resolves. The +lead is the clearest case -- `leadLine` rerolls its contour from +`saltedSeed + 7919 * intervalIndex`, which is a rule that says "never repeat". + +The cheap fix is that **every bot already knows `intervalIndex`**, so every bot +can evaluate the same function of it and arrive at the same structure with no +listening and no coordination. That is the third use of this trick -- one bot +acknowledges a key change, one bot answers a question, and now the whole band +follows one arc -- and it is worth recognising as the pattern it is: identical +inputs, identical deterministic function, agreement for free. + +**The enabling change is to split the seed in two.** Today one seed plus the +interval index decides everything, which is exactly why repetition and +staleness cannot be separated: repeating a phrase means reusing the seed, and +reusing the seed reproduces the interval sample for sample. So: + +- `figureSeed = f(roomSeed, voice, section)` decides **what** is played, and is + the same for every interval of the same section; +- `performanceSeed = f(roomSeed, voice, intervalIndex)` decides **how** it is + played, and is different every time. + +A phrase that returns is then the same music and a different take, which is +what the interlock at the bottom of this section asks for -- reached by +construction rather than by hoping the jitter is enough. + +**Two axes of repetition, and they are independent.** Both are missing and the +first is probably the larger win per unit of work: + +- *Within* an interval -- a phrase shorter than the interval, repeated. Every + figure currently spans the whole interval, so nothing recurs inside one. A + two-bar riff played four times is the difference between a riff and eight + bars of through-composed line. +- *Across* intervals -- the form. AABA. + +**The hard constraint, from the interval delay: form varies TEXTURE, never +HARMONY.** You hear the band a whole interval late, so the form you hear is +rotated against the form they are playing. That is harmless while every section +shares the chart -- a rotation of the same chords is the same chords. Give the +sections different chords and it becomes fatal: you would be soloing over a +progression you cannot hear. The chart stays one chart. + +**And the form is illegible unless something marks it.** A listener a whole +interval behind cannot infer where the phrase begins from the notes alone. The +turnaround is what makes the structure perceptible, which promotes it from +decoration to the thing that makes the rest of this audible at all. + +- [ ] **Split the seed**, per above. No audible change on its own -- with a + one-section form the band plays exactly as it does now -- which is what + makes it safe to land first and measure against. +- [ ] **Phrases that return.** A form table -- AABA, ABAC, AAAB -- indexed by + interval, so a phrase is a thing the listener can recognise coming back + rather than a fresh roll each time. The table and the section length come + from the room seed, so `shake` changes the shape of the music and not just + its notes. +- [ ] **Phrase length inside the interval.** A figure whose period is a half or + a quarter of the interval, repeated, rather than one that spans it. Seed + chosen per voice, since a bass riff and a lead line do not want the same + answer -- and the bass figure is already nudged AWAY from repeating + inside the interval on purpose, so that rule becomes a choice rather than + a constant. +- [ ] **Starting a tune starts the form.** `BandPlayState` going from Silent to + Playing should reset the form origin, or the band comes in mid-structure + -- which is not what "start playing" means. The play states already exist; + this is where they meet the form. +- [ ] **A shared intensity curve.** One deterministic arc over a section, read + by every voice and mapped to its own parameters: hats thicken, the bass + gets busier, the keys add extensions, the lead climbs. Tension and release + without anybody hearing anybody. +- [ ] **Staggered rests.** A voice drops out for a bar at low intensity, with a + per-voice threshold from its salted seed so the drop-outs never coincide, + and a floor that guarantees somebody is always playing. Sparse stretches + and dense ones, rather than everyone stopping at once. +- [ ] **Turnarounds mark the form.** The drums already fill every fourth + interval; make that the section boundary rather than a fixed count. +- [ ] Deviation, so the form does not become its own kind of stale: an + occasional departure whose likelihood grows the longer a phrase has + repeated. With the seed split this has a natural home -- the departure is + a figure decision, so it belongs to the section seed and the repeat + count, not to the performance. +- [ ] **The keys should be able to comp.** Today `renderKeys` holds one + sustained chord per chord-span: it is a pad, and a pad is the only thing + the keyboard player ever does. A Euclidean figure of stabs with a short + hold, re-striking the current chord while the chart still decides which + chord it is, would give the band a rhythmic middle it does not have. + + **Seed-chosen**, like every other timbre decision here: some sessions + pad, some comp, some sit between. That keeps `shake` meaningful and means + the question "which is right" does not have to be answered. + + The envelope is the actual work and it wants ears rather than a rule. + `PadPatch` was shaped for chords that ring into each other -- its release + is two seconds -- and a stab is a different instrument's gesture. This is + an `AntiphonVoiceLab` job (`libs/jambot/docs/BOT-CHAT.md` has no opinion on it). +- [ ] **Being told a form.** `band, play ABACBA` as a chat intent: parse a + letter string, bound its length, store it in `Settings`. Cheap once the + mechanism exists and worth having last rather than first -- the default + form has to be good before choosing one is interesting. + +**One interlock to get right.** `test/BotBandTests.cpp` asserts that two +consecutive drum intervals are not bit-identical -- today the hat rotation +carries that -- and genuine repetition is exactly what would break it: AABA +puts two A intervals next to each other, and under one seed they would be the +same samples. The answer is not to weaken the test. It is the seed split above: +repetition is identical in its *figure* and never in its *performance*, which +is what the swing and per-hit jitter in the synthesis work provide. A phrase that returns played +exactly the same way twice is a loop; played fractionally differently, it is a +band. The two pieces of work want doing in that order. + +- [ ] **A seed should not change the volume.** The kit's integrated loudness + varies by 3.7 LU across seeds, purely because a busy Euclidean figure has + more hits in it than a sparse one -- so `shake` currently changes how loud + the band is as well as what it plays, and it bounds how precisely the + band can be balanced at all. Normalising each voice to a loudness target + at render time would fix both. `AudioMeasure::integratedLufs` is the + instrument; the cost is one extra pass over the interval. + + Half of this is already done for the keys, and the half that is done is + the half a constant can fix. A brass patch is a driven near-square through + a filter that opens on every note and a strings patch is two saws barely + driven, so the seed's choice of patch was worth 6.4 LU on its own; + `PadPatch::level` is a measured per-character correction and takes the + spread across fourteen seeds to 2.4 LU. What is left is the same thing + the kit has -- how many notes the voicing put where -- and no constant + touches it. +- [ ] **The unit suite takes two minutes, and that is now an iteration cost.** + It grew honestly -- most of it is rendering audio and measuring it, which + is what the band tests are for -- but BotBand alone is 57 seconds and the + loop between an edit and an answer is long enough to discourage running it. + Worth an hour with a profile: shorter renders where a defect shows in the + first note, fewer redundant seeds, and possibly a `--quick` subset for the + edit loop with the full sweep left to CI. +- [ ] **Tab completion in the chat field.** Complete `/` commands from the + command list, and usernames after `/msg` and `/kick` from the room's user + list -- and a name at the start of a line, which is how a bot is addressed + (`libs/jambot/docs/BOT-CHAT.md` section 5). Common prefix first, then cycling. + Accessibility is half the point: the completion and the candidate list + both want announcing, and a name nobody can spell is a name nobody can + reach. +- [ ] **Resolve `/msg` and `/kick` against the user list, not whitespace.** + Both split on the first space, so neither can reach a username containing + one. Longest match against the names actually in the room fixes it, and + is what makes tab completion and hand-typing agree. + +### Sampled instruments, alongside the models + +Not scheduled, and deliberately not started while the synthesis plan has three +steps left -- two half-finished engines would be worse than one finished one. +Recorded because the analysis is done, and because checking it changed the +answer twice. + +**The player has to be FluidSynth, and that is now practical.** GeneralUser GS +makes heavy use of SoundFont modulators, and its own documentation names the +synths that render it correctly: FluidSynth 1.0.9 or later, BASSMIDI, MuseScore +2.0.3+, SynthFont2, VSTSynthFont. TinySoundFont is not among them, so the +one-MIT-header option is out for this bank. + +FluidSynth was previously unusable here for one reason -- it dragged in glib, +which is exactly the framework `PRINCIPLES §6` refuses. **That is fixed +upstream.** Since 2.5.0 it builds with `-Dosal=cpp11 -Denable-libinstpatch=0` +and no glib at all, and the glib path is deprecated for removal in 2.6.0. With +drivers, libsndfile and libinstpatch all disabled it is a small static library +with no dependencies we do not already have. + +Ardour vendors a trimmed FluidSynth in `libs/fluidsynth`, which is a worked +precedent for a GPL audio project doing exactly this. A submodule is preferable +to a fork we would then own. + +**The other compatible players were surveyed, and only one is a real +alternative -- which turns out to be a lighter fork of the same engine.** + +| Player | Library form? | Verdict | +|---|---|---| +| BASSMIDI | Yes, cross-platform | **Out on licence.** BASS is proprietary and closed, free only for non-commercial use. GPLv3 cannot link against it and be distributed, whatever its quality. | +| MuseScore | Not separable | **It is FluidSynth.** MuseScore's SF2 engine is a modified FluidSynth; its own Zerberus synth is SFZ-only and was removed in MuseScore 4. A second vendoring precedent rather than a second option. | +| SynthFont2 / VSTSynthFont | No | Closed source, Windows only. Out twice over. | +| **FluidLite** | Yes | **The real alternative, and possibly the better one.** | + +FluidLite is a stripped fork of FluidSynth built to have no external +dependencies at all -- standard C only -- and to keep just the settings and +synth. It deliberately omits MIDI file reading, realtime MIDI and audio output, +which is precisely the surface we do not want, because the conductor drives the +notes and JUCE takes the audio. LGPL-2-or-later, so the licence reasoning below +is unchanged. There is no glib question because there was never a glib. + +Two things to establish before preferring it. It is derived from FluidSynth +**1.x**, and GeneralUser GS wants 1.0.9 or later, so it is nominally in range -- +but whether the fork kept full modulator support is a question to answer by +RENDERING something and listening, not by reading a README. And it is less +actively maintained than mainline, across several forks (divideconcept, katyo, +batlogic), which is a real cost against a build that is otherwise much simpler. + +So: FluidLite first if it renders the bank correctly, mainline FluidSynth as the +known-good fallback. Both are the same licence and the same reasoning. + +**FluidLite has a known SF3 loop-point bug, and the fix is one character.** +Already found and patched against this same GeneralUser GS bank +(`fluidlite-sf3-loop-offbyone.patch`). Written down here so nobody +rediscovers it, because every symptom points away from the loader. + +*Symptom.* Sustained piano notes repeat every ~2 s, quietly, like a delay with +very low feedback: the whole sample loops instead of its sustain loop. It hits +some patches and not others -- Grand Piano and Bright yes, E.Grand and E.Piano +no -- so it reads as a bad patch, or as a bad SF2 -> SF3 conversion. It is +neither, and both were ruled out by controls: mainline fluidsynth 2.4.8 renders +the same SF3 clean, and the source SF2 clean. + +*Cause.* In `fluid_defsfont_get_sample`, in the SF3 branch only, an Ogg sample +is decoded and then `sample->end = sampleframes - 1` -- the LAST VALID INDEX. +But `loopend` per the SoundFont spec is the first sample AFTER the loop, an +EXCLUSIVE bound. FluidLite knows that; `fluid_voice.c:1795` says so in as many +words (*"'end' is last valid sample, loopend can be + 1"*). The validity check +three lines below the decode compares the exclusive bound against the inclusive +index: + +```c +if (sample->loopend > sample->end || ...) +``` + +so every sample whose loop runs to the very end -- `loopend == end + 1`, which +is legal and common -- is judged "fowled" and repaired to `loopstart = start + +8; loopend = end - 8`, which loops the entire sample. Most of GeneralUser's +Grand Piano samples loop to the end; the E.Piano samples loop well short of it, +which is exactly the split observed. **SF2 never reaches this code**, so the bug +is confined to the format the size table below otherwise argues for. + +*Fix.* `sample->loopend > sample->end + 1`, applied as a patch at configure time +rather than a fork -- the same mechanism `patches/` already uses here. + +*Measured*, by holding a C4 on program 0, rendering 14 s and scanning the +decaying envelope for re-attacks (a monotonic decay has none): **4 re-attacks at ++1.6 dB spaced ~2.0 s before, 0 after**, against 0 for both controls. + +One thing deliberately left unverified: the third clause of the same check, +`loopstart <= sample->start`, looks off by one too. By then `loopstart` has been +rebased to an offset from `start` (`fluid_defsfont.c:3245`) and `start` is 0, so +a loop beginning at frame 0 would also be "repaired". Nothing in this bank +appears to do that, so it was never measured and is not in the patch. + +**Licensing is a non-issue, which is not obvious.** FluidSynth is +LGPL-2.1-or-later, and LGPL's static-linking condition is that the user must be +able to relink against a modified library. Antiphon is GPLv3, so the entire +source is published anyway and the condition is satisfied by construction. +Nothing extra to do beyond a `THIRDPARTY.md` entry. + +**Real-time safety is a non-issue too, and only for this use.** The band renders +on the conductor thread, one interval at a time -- about half a second of work +against a four-second deadline -- so FluidSynth may allocate and lock as much as +it likes. `PRINCIPLES §7` is not engaged at all. This would be a completely +different proposition for a sampled instrument on the audio thread, and that +difference is the whole reason this is cheap. + +**One synth, not four.** Each `fluid_synth_t` loads its own copy of the sample +data, so a synth per bot is four copies of a thirty-megabyte bank in memory. One +synth with a MIDI channel per voice, rendered a voice at a time, keeps it to +one -- and the bots already render serially on a single conductor thread, so the +sharing costs no synchronisation. + +**On bundling: an earlier note in this file called the provenance caveat +"decisive", and that was overstated.** The facts: the GeneralUser GS v2.0 +licence explicitly permits use and modification in software projects; the +caveat is a DISCLOSURE by the author that he cannot account for every sample's +origin, aimed at people shipping commercial products; and several Linux +distributions package and redistribute it regardless. For a GPLv3 project this +is a judgement rather than a bar, and the honest reading is that bundling is +defensible with a residual risk that is disclosed, accepted by others, and +cheap to remedy. + +**SF3 changes the weight question, and costs us nothing to support.** SoundFont +3 is the same format with the samples Ogg Vorbis compressed -- an extension +Werner Schweer created for MuseScore for exactly this reason. The decompression +is free to us: FluidLite builds SF3 support against Xiph's libogg and libvorbis, +**which this repository already vendors as submodules** because the Ninjam codec +needs them. So the whole feature adds one small library and no new third-party +code at all. + +**Measured, by converting the bank at every quality setting:** + +| quality | size | of SF2 | marginal cost per 0.1 step | +|---|---|---|---| +| 0.1 | 5.85 MB | 19.0% | -- | +| 0.3 | 6.74 MB | 21.9% | +436 KB | +| 0.5 | 8.00 MB | 26.0% | +760 KB | +| **0.8** | **10.07 MB** | **32.7%** | +856 KB | +| 0.9 | 11.34 MB | 36.8% | +1304 KB | +| 1.0 | 13.38 MB | 43.4% | +2092 KB | +| SF2 | 30.82 MB | 100% | -- | + +Two things fall out of that curve. **The knee is at 0.8**, which is also where +the conversion guidance sits for quality reasons -- below it each step costs +about 550 KB and above it about 1400, nearly twice as steep, so the last fifth +of the quality range buys the least and costs the most. And **even the top +setting is 2.3x smaller than the SF2**, so there is no configuration in which +shipping the uncompressed bank makes sense. + +**At 10 MB the weight objection largely dissolves**, which is a change from the +position recorded above against 30. It has to be a data file rather than JUCE +binary data -- embedded it would be 40 MB across four plugin formats, and in git +it would be permanent -- but 10 MB fetched at package time and verified by hash +is unremarkable. + +**The better question these numbers raise is why ship 128 instruments at all, +and the answer has now been measured rather than guessed.** +`scripts/trim_soundfont.py` keeps a chosen set of presets and drops the rest, +following the preset-bag-generator-instrument-sample chains outward and +renumbering every one of them. + +The obvious guess about what that saves is WRONG, and worth recording. Dropping +264 of GeneralUser GS's 287 presets -- 92% of them -- removes only 59% of the +bytes. The sound effects are cheap, a fraction of a second each; the expensive +presets are exactly the ones worth keeping, because a convincing piano or string +section is many megabytes of multisampling. A quarter of the presets gives about +40% of the size, not 25%. + +It compounds with SF3 though, and that is where it pays: + +| set | presets | SF2 | SF3 at q0.8 | +|---|---|---|---| +| minimal | 8 | 7.16 MB | 1.90 MB | +| core | 23 | 12.42 MB | 3.55 MB | +| core + 8 kits | 31 | 16.53 MB | 4.82 MB | +| **band + 5 acoustic kits** | **43** | **20.33 MB** | **6.16 MB** | +| everything but synths and effects | 99 | 27.63 MB | 8.57 MB | +| the whole bank | 287 | 30.82 MB | 10.07 MB | + +`core` is what a physical model will never do well: piano, vibes and marimba, +two organs, nylon and steel guitar, violin, cello, pizzicato, string ensemble, +choir, four brass, three saxes, oboe, clarinet, flute. Everything the band +already plays is left out, because modelling those is better. + +**The drum kits are the bargain, and the arithmetic is not obvious.** Each is +about 2.7 MB alone, but they share almost everything -- the GS kits are largely +one set of samples remapped with a few kit-specific pieces -- so the first costs +2.69 MB and the other seven cost 1.38 MB between them. + +Worth taking for a musical reason too. The modelled kit has three pieces; each +sampled kit has 65 samples, including five toms, ride, ride bell, crash, splash, +china, cowbell, tambourine, claves, congas, bongos, timbales, agogo, guiro, +cabasa, shaker and woodblock. None of that is a physical model anybody here is +going to write, and `ROADMAP` already carries "multi-tap clap, cowbell, rimshot +and toms" as deferred work. Percussion is also the best case for Ogg, since a +one-shot is never looped and loop artifacts are the whole risk. + +The five kept are the acoustic ones. **Electronic and 808/909 are dropped +because the modelled kit already is a synthesised one**, and does that job +better: it varies continuously with velocity and never repeats, which is exactly +what a drum-machine sample cannot do. **Room is dropped because the kit is +already put in a room of our own** (`BotDsp::Room`), and baking a second one +into the samples would be two rooms. + +That last point generalises: the sampled kits are a palette to extend the +modelled kit with -- toms, cymbals, hand percussion, colour -- not a replacement +for its kick, snare and hat. A machine-gunned snare is the classic sampler +failure and it is most audible on the thing you hear every bar. + +**And the intuition about dropping the synthesisers is the wrong one, which is +worth knowing before anybody acts on it.** Cutting the synths and the sound +effects -- the obvious first move -- saves 11% of the bytes, because they are +short and thin. Every megabyte is in acoustic multisampling, which is precisely +what any of these sets is keeping. So the choice is not "what do we throw away" +but "how much acoustic material do we want", and the honest range is 6 MB for +the band's own palette against 8.6 MB for everything acoustic in the bank. + +**The trim is provably lossless.** Rendering the same MIDI through the full bank +and through each trimmed one gives BIT-IDENTICAL output from FluidSynth -- not +"sounds the same" or "measures the same", but byte for byte. The only lossy step +is the Ogg conversion afterwards, whose error at q0.8 measures 27.8 dB below the +signal. + +At 3.55 MB the bundling argument is over: that is a tenth of the original, it is +smaller than the fonts already embedded in the plugin, and it makes the +committed-versus-fetched question uninteresting. What remains is only whether a +sampled voice earns its place at all, which is a listening question and still +first in the order below. + +The catch is quality rather than size, and it lands unevenly across exactly the +instruments we want. Lossy compression shows on short LOOPED samples, so a +sustained string or organ tone is the risk and a piano -- one-shot, long, never +looped -- is not. Since the wanted set includes both, the setting cannot be +chosen from the size table alone. + +It can be chosen by measurement, with what is already here: render the same part +through the SF2 and through each SF3, and compare with `AudioMeasure` and by +ear, which is the loop the voice lab exists for. `antiphon-voicelab file a.wav +b.wav --lufs` already does the level-matched A/B. + +So the bundling decision is worth reopening once a voice exists to judge, rather +than settled now. What follows is the argument as it stands against the +uncompressed bank; halve or quarter every number for SF3. + +What actually argues against bundling is weight, not licence: + +- Thirty megabytes as JUCE binary data, in four plugin formats, is roughly a + hundred and twenty megabytes installed and a generated source file nobody + wants to compile. +- In git it is permanent: every clone pays for it forever, in a project whose + stated ambition is to fit in your head. + +So if it is ever bundled, it is as a **data file fetched at package time by CI +and verified by hash**, installed once and found at runtime -- never committed +and never embedded. An in-app opt-in download is the third option and the most +expensive: HTTPS in a plugin that currently speaks only Ninjam, a progress and +error surface that has to be announced for a screen reader, an integrity check, +and a hosting commitment that outlives our interest in it. + +**The order below defers every one of those questions.** Nothing about bundling +has to be decided until a single sampled voice has been heard next to the model +it would replace, at which point we will know whether it is worth paying for. + +The musical caveat from the first draft stands unchanged: a sample is the same +recording every time, repetition is this band's specific enemy, and a General +MIDI bank has one or two velocity layers, so velocity moves volume and a filter +rather than articulation. Samples lose for everything the band currently plays +and win for what we will never model -- an acoustic piano, a brass section, +bowed strings, reeds. + +- [ ] Decide between FluidLite and mainline FluidSynth by rendering the bank + through both and listening for the modulator-dependent presets. Submodule, + not a fork; `THIRDPARTY.md` entry either way. Build SF3 support against the + libogg and libvorbis already vendored here. +- [ ] **If FluidLite wins: carry the SF3 loop-end patch from the first day**, as + `patches/fluidlite-sf3-loop-offbyone.patch` -- written up above, and one + character. Take the re-attack scan with it, as a test rather than a + listening note: a held C4 rendered long and scanned for a rise in a + decaying envelope is a cheap assertion, and it is the only thing that + catches this class of fault. Check upstream first, in case it has landed. +- [ ] Load an SF2 from a path the player chooses. No bundled bank, so no + packaging or provenance question yet. +- [ ] One shared synth, a channel per voice, driven from the conductor thread. +- [ ] One voice at a time, selectable like the lead's instruments, so the + comparison against the model is direct, and measured with `AudioMeasure` + like everything else. +- [ ] Through the existing per-note tone, drift and saturation chain rather than + straight out -- which is also what a real sampler does to stop notes + machine-gunning. +- [ ] Layering -- a sampled attack over a modelled body -- once a single sampled + voice has been lived with. +- [ ] Compare an SF3 conversion against the SF2 on the same part, measured, to + see whether the compression is audible on looped samples. +- [ ] Only then, and only if it earned its place: whether to ship a bank, in + which format, and fetched at package time rather than committed. + +### Breaking the repository up [done] + +*(2026-08-18/19.)* Done, and it went the other way round from the analysis that +used to sit here: nothing was restructured around the practice room. What left +were four pieces of general-purpose code this repository happened to hold -- +music theory, DSP primitives, the wire protocol and the loudness meter -- each +now an MIT, strictly JUCE-free library that builds and tests standalone, and +each consumed here as a submodule under `libs/`. + +The long analysis that reached that decision covered more than this project, so +it is not kept here; the reasoning and the standing argument live in the +ecosystem plan. Two things that analysis had wrong are worth recording, since +they are what changed the answer: the shared libraries are strictly JUCE-free, +so the `juce::String` dependency in the music layer went away and with it the +objection that every layer needs JUCE; and the Scala tuning parser it missed +entirely is a first-class part of `chalkwalk-music`. + +- [ ] Correct the line count in `AGENTS.md`, which is out by a factor of three. + +### A responsive jamming partner + +Sketched in `libs/jambot/docs/BOT-CHAT.md` section 14, and not scheduled. A bot receives a +whole interval at once and composes a whole interval at once, so it holds your +complete phrase -- ending and all -- at the moment a human listener has heard +only its first beat, and it answers into the same slot they would. It can +therefore be more responsive than a player in the room, while staying entirely +inside the form. + +- [ ] Decide whether this is wanted at all before building any of it. +- [ ] Analysis as a bias on the existing generator rather than a replacement, so + that with no analysis the band plays exactly as it does now. +- [ ] Rhythm and density before pitch: much cheaper, and most of the effect. + Key detection from audio is its own project and is not this. + +### Split the client out + +> **Done, 2026-08-19, as the protocol rather than the client.** +> [`chalkwalk-ninjam`](https://github.com/chalkwalk/chalkwalk-ninjam) is a +> submodule at `libs/ninjam` and carries `NinjamProtocol`, `VorbisCodec`, +> `Sha1`, `IntervalClock`, `SpscRing` and `ChannelMix` under MIT, with the +> provenance note `PRINCIPLES §6` required. +> +> The survey changed the unit. This entry proposed moving the *client*, but +> `NinjamClient` carries `juce::File`, `juce::AudioBuffer` and forty-odd locks +> -- host concerns a protocol library has no business owning. The protocol +> underneath it was already JUCE-free in five files of six, and is the part +> nothing else on the shelf provides. So the client stayed and the wire format +> left. +> +> Five of the six moved as a using-declaration each: their APIs did not change, +> so not one call site here did either. `NinjamProtocol` did change -- +> `juce::MemoryBlock` became `ByteBuffer` and `juce::String` became +> `std::string` -- and cost about a hundred small conversions across +> `NinjamClient`, `PracticeServer`, `FakeNinjamServer` and two test files. +> `juce::String` constructs implicitly from `std::string`, so parsed fields +> still flow into the UI untouched; the other direction is an explicit +> `.toStdString()` at each site, which is where the boundary now shows. +> +> The extraction paid for itself immediately: linking against the library +> aborted the handshake, and the cause was three `juce::jlimit(lo, hi, value)` +> calls transcribed as `std::clamp(lo, hi, value)` during the port. Fixed and +> covered in the library, where neither builder had had a test at all. + + +`NinjamClient`, `NinjamProtocol`, `VorbisCodec`, `Harmony` and the bots have no +dependency on the plugin -- `tools/StemsMain.cpp` and the wanted +`tools/BotMain.cpp` already prove it. Making them their own repository, consumed +here as a submodule with the bots a submodule of that, would let a bot travel +with the client rather than with the plugin. + +It is a packaging decision rather than a code one, and it costs a repository +boundary in exchange for reuse nobody has asked for yet. Written down because +the thought recurs, not because it is scheduled. + +**Superseded in detail by *Breaking the repository up*,** which measured the +dependency direction rather than assuming it and found four layers where this +entry assumes one boundary. Kept as the shorter statement of the same recurring +thought; decide both together or not at all. + +- [ ] Decide, and if the answer is no, move this to `NON-GOALS.md` with the + reason. --- @@ -467,19 +1490,32 @@ elsewhere. What CI actually found: - **Windows initially failed to configure at all**, which was a defect in the workflow, not the project: `-G Ninja` made CMake take MinGW g++ off the runner's PATH, and JUCE rejects MinGW outright. Dropping the generator flag on - Windows gets Visual Studio and MSVC, which is what arps-euclidya does and why - it never hit this. MSVC 19.51 then compiled the tree without complaint. + Windows gets Visual Studio and MSVC, which is the configuration that avoids + it. MSVC 19.51 then compiled the tree without complaint. - [ ] Confirm what the plugin does once *loaded* on macOS and Windows. Building - and passing headless tests is a long way from a host instantiating it: - nothing has yet opened a window, opened a device, or joined a jam there. + and passing headless tests is a long way from a host instantiating it. + **macOS: done once, by a contributor, via the AU -- see below. Windows is + still untouched**: nothing there has opened a window, opened a device, or + joined a jam. - [x] macOS: decide whether AU is in scope. **It is, and it is built** -- `FORMATS` gains AU under `if(APPLE)`. Logic Pro and GarageBand load no other format, so without it macOS support means "every DAW except the two most common ones". -- [ ] Confirm the AU actually loads. It has never been compiled: development is - on Linux, so CI is the first machine to build it and no host has - instantiated it. Until then the format is a claim, not a fact. +- [x] Confirm the AU actually loads. **It does.** A contributor built it on + their own Mac, loaded it in a host, joined a jam and used it with a screen + reader -- the whole path, not just instantiation. That retires the "the + format is a claim, not a fact" caveat this line used to carry. +- [ ] **Keep it that way: the AU is not regularly tested.** One report from one + machine, at one point in the history, by hand. Development is on Linux, CI + only compiles the AU, and nothing automated instantiates it anywhere -- so + the next AU regression will be found by a person or not at all. What would + change that, cheapest first: `auval` in the macOS CI job, which validates + an AU without a DAW and needs no window session; then a named macOS + smoke-test pass before each release. Until one of those exists, treat + "the AU works" as true-as-of-a-date rather than as a standing guarantee, + and re-check it by hand after anything touching buses, the editor or + startup. - [ ] AU is one stereo bus in, one stereo out, deliberately. JUCE's AU wrapper drops the `busLayoutChanged` notification our patch adds (`DESIGN.md` §"AU is one bus in, one bus out"), so the bus controls are diff --git a/THIRDPARTY.md b/THIRDPARTY.md index b5f6fae..5150c68 100644 --- a/THIRDPARTY.md +++ b/THIRDPARTY.md @@ -21,6 +21,21 @@ required -- but that constraint binds anyone who re-generates or subsets them. | **libogg / libvorbis** | `modules/ogg`, `modules/vorbis` (submodules) | BSD-style (Xiph) | Ogg/Vorbis encode and decode. | | **clap-juce-extensions** | `modules/clap-juce-extensions` (submodule) | MIT | CLAP plugin format support. | +## Data + +| Component | Path | Licence | Notes | +|---|---|---|---| +| **SCOWL** word list | `src/BotDictionary.h` (generated) | Permissive, attribution required | The real-word gate for the practice room's chat parsing: a word that is ordinary English is not a mistyped one. Not the whole list -- `scripts/make_wordlist.py` keeps only the words within the typo-repair budget of a `BotLanguage` lexicon entry, which is the only place a dictionary can change a decision. | + +**SCOWL obligation.** Spell Checker Oriented Word Lists, Copyright 2000-2011 +Kevin Atkinson, taken from the Debian `wbritish` package. Use, copy, modify, +distribute and sell are all granted without fee, provided the copyright notice +and permission notice appear in copies and in supporting documentation -- +which this section is, and which the generated header repeats in its own +comment so the notice travels with the file. The word lists come with no +warranty. Constituent lists include the public-domain Moby Words II. GPLv3 +imposes nothing further here: the terms are strictly more permissive. + That table is the whole list. In particular: - **No WDL.** Antiphon began by vendoring two Cockos WDL headers, `sha1` and diff --git a/cmake/ChalkwalkLibrary.cmake b/cmake/ChalkwalkLibrary.cmake new file mode 100644 index 0000000..4150a7f --- /dev/null +++ b/cmake/ChalkwalkLibrary.cmake @@ -0,0 +1,85 @@ +# --------------------------------------------------------------------------- +# Where the shared libraries come from. +# +# Unset, nothing changes: this repository's submodules are used and a fresh +# clone builds with no extra steps. The submodule stays the source of truth for +# WHICH commit this project wants. +# +# CHALKWALK_MUSIC_DIR, CHALKWALK_DSP_DIR, CHALKWALK_NINJAM_DIR -- cache +# variables or environment variables -- point at working checkouts instead, +# which is what makes a change to a library testable HERE without a commit and +# without a push: +# +# cmake -B build -DCHALKWALK_MUSIC_DIR=$HOME/Programming/chalkwalk-music +# +# Antiphon then compiles that working tree directly. Edit there, rebuild here, +# run the suite; no round trip through GitHub. That matters most for the +# library that is still growing: a change to `Harmony` wants Antiphon's 3,000 +# assertions run against it before it is committed anywhere. +# +# THE SUBMODULE SHA NO LONGER DESCRIBES WHAT YOU BUILT while one of these is +# set, which is the whole cost of it. CI must not use them, and neither should +# anything whose result is meant to be attributable -- `docs/PARITY.md`'s +# measurements above all, since a number that cannot name the commit that +# produced it is not a measurement. Use an override to iterate; bump the +# submodule and re-verify before calling anything done. +# +# Same shape as Anvil's CHALKWALK_PHYSICAL_DIR, deliberately: one pattern +# across the ecosystem is worth more than a better one used in one place. +# --------------------------------------------------------------------------- + +function(chalkwalk_add_library name submodule_path) + # Already added by a parent, so use theirs. + # + # These libraries nest: Antiphon pulls in chalkwalk-jambot, which pulls in + # the same chalkwalk-music, -dsp and -ninjam that Antiphon has already + # added. Adding a second copy is not a version conflict -- it is a + # duplicate CMake target name, which fails the configure outright. + # + # Whichever project adds it first wins and the rest reuse it, which is the + # same rule chalkwalk-ninjam applies to its vendored ogg and vorbis. It + # also means the OUTER project's submodule SHA is the one that describes + # the build, and the inner one is not consulted at all -- so a nested + # library does not need its own submodules checked out. + if(TARGET chalkwalk_${name}) + message(STATUS "chalkwalk-${name}: already provided by a parent project") + return() + endif() + + string(TOUPPER "${name}" upper) + set(var "CHALKWALK_${upper}_DIR") + + if(NOT ${var} AND DEFINED ENV{${var}}) + set(${var} "$ENV{${var}}") + endif() + set(${var} "${${var}}" CACHE PATH + "Working checkout of chalkwalk-${name}; empty means use this repository's own submodule") + + if(${var}) + if(NOT EXISTS "${${var}}/CMakeLists.txt") + message(FATAL_ERROR + "${var} is set to '${${var}}' but there is no chalkwalk-${name} " + "there. Point it at a checkout, or unset it to use the submodule.") + endif() + set(root "${${var}}") + message(STATUS + "chalkwalk-${name}: OVERRIDE at ${root} " + "(the submodule SHA does not describe this build)") + else() + set(root "${CMAKE_CURRENT_SOURCE_DIR}/${submodule_path}") + if(NOT EXISTS "${root}/CMakeLists.txt") + message(FATAL_ERROR + "No chalkwalk-${name}.\n" + " This repository's ${submodule_path} submodule is not checked " + "out, and ${var} is not set. Either:\n" + " git submodule update --init --recursive\n" + " or point at a working checkout:\n" + " cmake -B build -D${var}=/path/to/chalkwalk-${name}") + endif() + endif() + + # Each library's own suite runs inside this project's ctest, so Antiphon + # verifies its dependencies rather than assuming them. + set(CHALKWALK_${upper}_TESTS ON CACHE BOOL "" FORCE) + add_subdirectory("${root}" "${CMAKE_BINARY_DIR}/libs/${name}") +endfunction() diff --git a/cmake/CheckMusicLayerIsJuceFree.cmake b/cmake/CheckMusicLayerIsJuceFree.cmake new file mode 100644 index 0000000..873d1fe --- /dev/null +++ b/cmake/CheckMusicLayerIsJuceFree.cmake @@ -0,0 +1,49 @@ +# What is still on its way out must not reach for JUCE. +# +# Almost everything this guarded has arrived: `Harmony` and the key are in +# `chalkwalk-music`, the room conventions in `chalkwalk-ninjam`, both strictly +# JUCE-free. What is left is the glue and the policy that have not moved yet. +# +# `MusicalKey.h` composes the envelope with the notation -- three inline +# functions -- and `RoomHarmony.h` is what a chat line does to a room. The +# second travels with `PracticeBot` when the bots leave, so it has to stay +# JUCE-free until it does; the first is small enough that the cost of checking +# is nil and the cost of noticing late is a build that will not extract. +# +# This is a test rather than a convention because the failure is silent and +# late: one `juce::String` added in passing still builds, still passes, and is +# only discovered when somebody tries to move the file. Cheap to check, and it +# fails on the line that broke it. The same shape as +# CheckNoStandaloneMacro.cmake, and for the same reason. + +set(GUARDED + MusicalKey.h + RoomHarmony.h) + +set(OFFENDERS "") +foreach(name ${GUARDED}) + set(path "${SRC_DIR}/${name}") + if(NOT EXISTS "${path}") + message(FATAL_ERROR "guarded file is missing: ${path}") + endif() + + file(STRINGS "${path}" lines) + set(lineNumber 0) + foreach(line ${lines}) + math(EXPR lineNumber "${lineNumber} + 1") + # Comments may discuss JUCE -- several explain why it is not here. + string(REGEX REPLACE "//.*" "" code "${line}") + if(code MATCHES "juce::|JuceHeader|JUCE_") + list(APPEND OFFENDERS "${name}:${lineNumber}: ${line}") + endif() + endforeach() +endforeach() + +if(OFFENDERS) + string(REPLACE ";" "\n " report "${OFFENDERS}") + message(FATAL_ERROR + "The music-theory layer must stay JUCE-free:\n ${report}\n" + "Use src/TextUtil.h, or std::string directly. See src/MusicalKey.h.") +endif() + +message(STATUS "music layer is JUCE-free") diff --git a/cmake/JuceSource.cmake b/cmake/JuceSource.cmake new file mode 100644 index 0000000..5efb6a7 --- /dev/null +++ b/cmake/JuceSource.cmake @@ -0,0 +1,56 @@ +# --------------------------------------------------------------------------- +# Where JUCE comes from. +# +# JUCE is 94 MB of working tree and four plugins in this ecosystem pin the same +# commit, so four checkouts is 376 MB of the same files. CHALKWALK_JUCE_DIR -- +# a cache variable or an environment variable -- points at one shared checkout +# instead. +# +# Unset, nothing changes: this repository's own JUCE submodule is used and a +# fresh clone builds with no extra steps. That is deliberate. The submodule +# stays the source of truth for WHICH commit this project wants, and sharing is +# an optimisation a developer opts into, not a dependency. +# +# To use it, and free the local checkout: +# +# cmake -B build -DCHALKWALK_JUCE_DIR=$HOME/Programming/.juce/JUCE +# git submodule deinit JUCE # the pin stays recorded in git +# +# THE SHARED CHECKOUT CARRIES THE UNION OF EVERY PROJECT'S JUCE PATCHES. They +# touch disjoint files today, so the union is well defined -- but it does mean +# building against patches another plugin needed. That coupling is the price of +# one checkout and it is accepted knowingly. +# --------------------------------------------------------------------------- +if(NOT CHALKWALK_JUCE_DIR AND DEFINED ENV{CHALKWALK_JUCE_DIR}) + set(CHALKWALK_JUCE_DIR "$ENV{CHALKWALK_JUCE_DIR}") +endif() +set(CHALKWALK_JUCE_DIR "${CHALKWALK_JUCE_DIR}" CACHE PATH + "Shared JUCE checkout; empty means use this repository's own submodule") + +if(CHALKWALK_JUCE_DIR) + if(NOT EXISTS "${CHALKWALK_JUCE_DIR}/CMakeLists.txt") + message(FATAL_ERROR + "CHALKWALK_JUCE_DIR is set to '${CHALKWALK_JUCE_DIR}' but there is no " + "JUCE there. Point it at a JUCE checkout, or unset it to use the " + "submodule.") + endif() + set(CHALKWALK_JUCE_ROOT "${CHALKWALK_JUCE_DIR}") + message(STATUS "JUCE: shared checkout at ${CHALKWALK_JUCE_ROOT}") +else() + set(CHALKWALK_JUCE_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/JUCE") + # CHALKWALK_JUCE_OPTIONAL: set by a project that can do something useful + # with no JUCE at all -- a project may build and test a JUCE-free core that way, + # and turning that into a hard error would destroy the boundary it exists + # to prove. Such a project checks CHALKWALK_JUCE_ROOT itself. + if(NOT EXISTS "${CHALKWALK_JUCE_ROOT}/CMakeLists.txt" AND NOT CHALKWALK_JUCE_OPTIONAL) + message(FATAL_ERROR + "No JUCE.\n" + " This repository's JUCE submodule is not checked out, and " + "CHALKWALK_JUCE_DIR is not set. Either:\n" + " git submodule update --init --recursive\n" + " or point at a shared checkout:\n" + " cmake -B build -DCHALKWALK_JUCE_DIR=/path/to/JUCE\n" + " Without this the failure is a bare " + "add_subdirectory error that says nothing about either option.") + endif() +endif() diff --git a/docs/ACCESSIBILITY.md b/docs/ACCESSIBILITY.md index 8ea2279..e7f7c1f 100644 --- a/docs/ACCESSIBILITY.md +++ b/docs/ACCESSIBILITY.md @@ -170,11 +170,26 @@ without a name. See `test/README.md`. **What the audit does not tell you** is whether the result is pleasant to use. It cannot judge whether a description is helpful, whether the tab order feels sane, or whether announcements land at useful moments. That needs a real screen -reader user, and no claim is made here that it has been verified that way. +reader user. + +**It has now been used by one.** A contributor built the AU on macOS, loaded it +in a host, joined a jam and worked the plugin with a screen reader, and reported +that it compares favourably with the official client. That is the first evidence +any of this works where it counts, and it is worth stating plainly because the +rest of this document is deliberately pessimistic. + +It is also worth bounding just as plainly: **one person, one platform, one +session, reported verbally rather than as a list of findings.** It says the +approach is sound. It does not say any particular control reads well, it says +nothing at all about Windows or NVDA, and it is not a substitute for the gaps +below -- most of which it never touched. ## Known gaps -- Not verified with an actual screen reader by the authors. +- Verified with a screen reader once, on macOS, as above. Not on Windows, not + repeatedly, and not in a way that produced actionable detail. +- Not verified with an actual screen reader by the authors, as distinct from by + a contributor. - JUCE's stock `AudioDeviceSelectorComponent`, which the standalone's recovery screen embeds, labels its Output and Input dropdowns for sighted users only: the labels are attached with `Label::attachToComponent` and no accessible diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 12c6b64..d901b45 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -169,6 +169,119 @@ type-specific: Voting (`!vote bpm `, `!vote bpi `), `/me`, `/topic`, `/kick` and `/msg` are all sent through this message. +### Tempo and interval limits: two paths, two different ranges + +There are **two** ways to change BPM or BPI, they do not accept the same values, +and confusing them produces a failure that is very hard to diagnose from the +outside. Identical in the reference server and libninjam: + +| | Range | Gate | Out of range | +|---|---|---|---| +| `!vote bpm ` | **40..400** | anyone | see below | +| `!vote bpi ` | **2..64** | anyone | see below | +| `/bpm ` | **20..400** | `PRIV_BPM` | "BPM parameter must be between 20 and 400" | +| `/bpi ` | **2..1024** | `PRIV_BPM` | "BPI parameter must be between 2 and 1024" | + +The vote limits are `MIN_BPM`/`MAX_BPM`/`MIN_BPI`/`MAX_BPI` in +`justinfrankel/ninjam server/usercon.h:57-60`, applied at `usercon.cpp:1169` +and `:1174`. The admin limits are literals at `usercon.cpp:1481-1498`. + +**An out-of-range vote does not say so.** The range test is part of the same +condition that recognises the command, so failing it falls through to +`"[voting system] !vote requires parameters"` -- a complaint +about the command's *shape*, for a command whose shape was fine +(`usercon.cpp:1184`). A player reading that has no way to learn that 30 BPM was +the problem. Hence `ChatFormat::isVotableBpm`/`isVotableBpi`: we do not offer a +vote the server will refuse. + +**The two ranges must not be collapsed.** A BPI of 124 and a BPM of 39 are both +legal on the server and neither can be voted for -- and they persist across a +reconnect, so every client has to follow a room to values it could never have +proposed. **Never validate incoming `SERVER_CONFIG_CHANGE_NOTIFY` against the +vote range**; `test/NinjamProtocolTests.cpp` asserts we do not. + +**The reference client does not validate incoming config at all.** +`NJClient::updateBPMinfo` (`justinfrankel/ninjam njclient.cpp:725-732`) stores +`bpm` and `bpi` with no range test of any kind, and ReaNINJAM is built on it. +So "accept whatever the server says" is the canonical behaviour, not a liberty +we are taking, and Antiphon matches it. + +This is not hypothetical, and it is where JamTaba goes wrong. Its incoming +setter is guarded by its own limits with no `else` +(`elieserdejesus/JamTaba src/Common/ninjam/client/ServerInfo.cpp:112-123`), so +against a server at **39 BPM** it drops the value and **carries on displaying +the previous tempo** -- no error, no indication. Two clients in the same room +disagreeing about the tempo, with the one showing the *correct* value looking +like the broken one, is the confusing shape this causes. + +Two further traps, both observed rather than deduced: + +- **Clients impose their own, tighter limits, and fail silently at them, in + both directions.** JamTaba caps BPI at 192 (`ServerInfo.h:156`) and BPM at 40 + low (`ServerInfo.h:154`), and `ServerInfo.cpp:117,130` simply *ignore* a value + outside those bounds -- no error, no change. Outgoing, a BPI of 1024 typed + into JamTaba does nothing and the server never hears about it. Incoming, a + server at 39 BPM is not displayed. So a value being refused says nothing about + which side refused it, and a tempo on screen is not evidence of the tempo in + the room. +- **Be liberal in what you accept, conservative in what you inflict.** The two + halves are not symmetric. *Receiving*, match the reference client and follow + the room anywhere it goes. *Sending*, remember that a BPI above 192 leaves + every JamTaba user in the room unable to follow -- it keeps its previous + interval and desyncs outright, so setting one is not a private act. The + server permitting something is not the same as the room surviving it. +- **`MIN_BPM`/`MAX_BPI` are compile-time `#define`s, not configuration** + (`server/usercon.h:57-60`), so a server operator who wants a wider range + patches and rebuilds. A public server refusing a vote for 125 BPI while + sitting at 124 is exactly what a raised `MAX_BPI` looks like from outside. + Treat the limits above as the stock build, not as a guarantee. +- **`!vote` is BPM and BPI only.** `!vote key Cm` is rejected, and by the client + before it reaches the wire in JamTaba's case. The server's own answer to an + unknown `!command` is "Unknown !command. Commands available: !vote, !topic" + (`usercon.cpp:1288`). There is no key in the protocol at any level: a key is a + convention carried in ordinary chat, which is why `[key: ...]` exists. + +### The key: two forms, because neither can do the other's job + +NINJAM carries no key, so Antiphon puts one in ordinary chat. There are two +accepted forms and the difference is where each may appear: + +| Form | Matched | Why it exists | +|---|---|---| +| `[key: D minor]` | **anywhere** in a line | so it can ride in the room topic | +| `/key D minor` | only at the **start** of a line | so it can be talked about | + +The topic matters because the server sends it **only to a joining client** +(`usercon.cpp:195,407`, `Send` not `Broadcast`) and replays no chat at all. It +is the sole piece of room state a late arrival can inherit -- which is also why +it goes stale, so anything reading it should say where the value came from. + +The second form exists because the first is *unsayable*. Matching the tag +anywhere means any sentence explaining it performs it, so without a line-leading +alternative nothing could ever tell a player how to change the key -- it could +only change it for them. `MusicalKey::parseAnnouncement` accepts both; +`announcementAdvice` produces only the sayable one, and +`test/BotAnswerTests.cpp` asserts that no generated reply parses as a key. + +Other clients pass an unknown slash command through as ordinary chat, so `/key` +works from any of them -- verified against JamTaba. + +Chord charts need none of this: `| Am | F |` must already *begin* the line +(`Harmony.cpp:622`), so it is quotable mid-sentence and needs no second form. + +### The voting threshold + +`(vucnt * m_voting_threshold + 50) / 100` (`usercon.cpp:1239`) -- **round half +up**, not a ceiling. `vucnt` counts every user with `m_auth_state > 0`, so it is +everyone connected, whether or not they voted and whether or not they are a bot. +`SetVotingThreshold` is a server config percentage; `example.cfg:60` shows 50, +and notes that a value above 100 disables voting entirely. + +Two consequences worth stating: **not voting is voting against**, since the +denominator counts you either way; and anything Antiphon connects to a room +counts toward it. See `libs/jambot/docs/BOT-CHAT.md` for what that means for the practice +band. + --- ## Parsing rules diff --git a/docs/references/Jamtaba.md b/docs/references/Jamtaba.md index 838f538..0460717 100644 --- a/docs/references/Jamtaba.md +++ b/docs/references/Jamtaba.md @@ -23,3 +23,59 @@ - **Framework Choice**: Jamtaba proves that using a heavy framework like Qt for a plugin can be problematic (evident by the complex static-compile instructions for the VST version). JUCE is specifically designed for VST/AU/AAX plugin development, ensuring we won't face the same static-linking build nightmares on Windows and macOS. - **Complexity**: Jamtaba is "much more complex than what I want". We want to keep our plugin clean and simple, focusing specifically on operating inside a DAW (as an effect plugin on the master bus) rather than trying to become a standalone host. - **I/O Routing**: Our plan to have 8 input and 8 output buses that can dynamically be instantiated is a more flexible, DAW-centric approach than treating the plugin as a fixed standalone application. + +## Tempo and interval limits (read 2026-08-14) + +Read alongside the reference server to explain why a BPI of 1024 typed into +JamTaba does nothing at all, with no error shown. + +`src/Common/ninjam/client/ServerInfo.h:154-157`: + +``` +MIN_BPM = 40 MAX_BPM = 400 +MIN_BPI = 2 MAX_BPI = 192 +``` + +Two things follow, and both are traps for anyone comparing clients: + +- **These are JamTaba's own, and they are tighter than the server's admin path** + (which allows 2..1024 BPI and 20..400 BPM). A value JamTaba refuses may be + perfectly legal on the server. +- **The refusal is silent.** `ServerInfo.cpp:117` and `:130` apply the new value + only `if` it is in range, with no `else` -- so an out-of-range BPI is dropped + on the floor and the server never hears about it. Nothing appears in chat, + and the tempo simply does not change. + +JamTaba also rejects `!vote key ...` client-side before it reaches the wire, +which matches the server: `!vote` is bpm and bpi only. + +The practical lesson for us: **a value being refused tells you nothing about +which side refused it.** Ours are in `ChatFormat`, named for the path they +belong to. + +### The silent-drop bug, seen from outside + +`ServerInfo::setBpm`/`setBpi` (`ServerInfo.cpp:112-134`) are the **incoming** +setters -- what applies the tempo the server reports. Both are written as + +```cpp +if (bpm >= MIN_BPM && bpm <= MAX_BPM) { this->bpm = bpm; return true; } +return false; +``` + +with no `else`. Against a server legitimately at 39 BPM (settable by an admin, +below the 40 vote minimum), JamTaba therefore **keeps showing the previous +tempo** and never mentions it. Antiphon shows 39, which is correct, and looks +wrong beside it. + +Worth remembering when a bug report says "your client shows a different tempo +from JamTaba": the two clients disagreeing does not tell you which one is +following the server. + +For contrast, the reference client does none of this: +`NJClient::updateBPMinfo` (`justinfrankel/ninjam njclient.cpp:725-732`) assigns +`m_bpm` and `m_bpi` with no validation whatsoever. ReaNINJAM is built on that +client, so it is the canonical behaviour and JamTaba is the outlier. We match +the reference. There is no case for bug-for-bug parity here -- but there is a +case for not *creating* a room state JamTaba cannot follow, since it is the +most widely used client and it fails silently rather than loudly. diff --git a/docs/references/ninjam.md b/docs/references/ninjam.md index 1b779f8..e62996e 100644 --- a/docs/references/ninjam.md +++ b/docs/references/ninjam.md @@ -30,3 +30,76 @@ It heavily relies on **WDL** (Whale's Dev Library), another open-source C++ libr - We must implement the Ogg Vorbis encoding/decoding on roughly the same chunk logic. - The client must maintain strict interval timing, generating a local metronome, and pausing playback of remote streams until the interval boundary is hit. - The networking involves sending and receiving bespoke Ninjam protocol headers followed by the compressed Ogg payloads. + +## Tempo and interval limits (read and MEASURED 2026-08-14) + +Read to settle a confusing observation: a BPI of 124 was set from JamTaba +against a live server, persisted across a reconnect, and yet `MAX_BPI` is 64. + +Both are true, because there are **two** paths with **different** limits: + +| Path | BPM | BPI | Gate | Source | +|---|---|---|---|---| +| `!vote bpm\|bpi ` | 40..400 | 2..64 | anyone | `server/usercon.h:57-60`, applied `usercon.cpp:1169,1174` | +| `/bpm `, `/bpi ` | 20..400 | 2..1024 | `PRIV_BPM` | literals at `usercon.cpp:1481-1498` | + +- The strings "BPM parameter must be between 20 and 400" and "BPI parameter + must be between 2 and 1024" are **the server's**, from the admin path + (`usercon.cpp:1488,1497`). They are easy to mistake for a client's own + validation, because they arrive as an ordinary `MSG`. +- An out-of-range `!vote` is **not** told it was out of range. The bounds test + is folded into the same condition that recognises the subcommand, so failing + it falls through to "[voting system] !vote requires + parameters" (`usercon.cpp:1184`) -- a complaint about a command whose shape + was fine. +- `!vote` accepts **bpm and bpi only**. Any other `!command` gets "Unknown + !command. Commands available: !vote, !topic" (`usercon.cpp:1288`). There is + no key anywhere in the protocol. + +### Vote threshold + +`(vucnt * m_voting_threshold + 50) / 100` (`usercon.cpp:1239`) -- integer +division of a round-half-up, **not** a ceiling. `vucnt` counts every user with +`m_auth_state > 0` (`usercon.cpp:1192-1200`): everyone connected, whether they +voted or not. So not voting counts against the motion. + +`SetVotingThreshold` is a percentage set in the server config; `example.cfg:60` +documents "can be 1-100%, or >100 to disable". A client never has to know it -- +the threshold arrives as the denominator of `N/M` in the vote line. + +Consumed by `ChatFormat::isVotableBpm`/`isVotableBpi` and documented for players +in `docs/PROTOCOL.md`. + +### Measured, not just read + +Against a real `ninjamsrv` built by `scripts/testserver.sh` at the pinned +revision, with `SetVotingThreshold 50` and a user holding `CBTKV`. Every line +below is the server's own reply: + +``` +ADMIN 'bpi 125' -> CONFIG bpm=120 bpi=125 "tester sets BPI to 125" +ADMIN 'bpi 1000' -> CONFIG bpm=120 bpi=1000 "tester sets BPI to 1000" +ADMIN 'bpi 1025' -> "BPI parameter must be between 2 and 1024" +ADMIN 'bpm 39' -> CONFIG bpm=39 bpi=1000 "tester sets BPM to 39" +MSG '!vote bpi 125' -> "[voting system] !vote requires parameters" +MSG '!vote bpi 64' -> "[voting system] setting BPI to 64" (1 user, 50%) +MSG '!vote key Cm' -> "[voting system] !vote requires parameters" +``` + +Four things this pins down that reading alone left ambiguous: + +1. **The admin path really does reach 1024, and a BPM of 39 really is settable.** + A room can legitimately sit at 39 BPM / 1000 BPI. Any client that will not + display that is wrong about the room. +2. **The two paths produce completely different errors for the same number.** + `bpi 125` is accepted by ADMIN and rejected by `!vote`, and the `!vote` + rejection blames the command's *parameters*. Anyone diagnosing "125 was + refused" needs to know which path they used first. +3. **Without `PRIV_BPM` the admin path says so plainly** -- "No BPM/BPI + permission" -- and with `SetVotingThreshold` unset, voting answers + "[voting system] Voting not enabled". Neither is a range problem, and both + look like one from a distance. +4. **`!vote key Cm` is consumed and answered with an error.** It is *not* + relayed to the room as ordinary chat, so no other client ever sees it. Any + scheme that hoped to tally a key vote by watching `!vote key` lines in chat + cannot work -- see `libs/jambot/docs/BOT-CHAT.md`. diff --git a/libs/dsp b/libs/dsp new file mode 160000 index 0000000..389cc07 --- /dev/null +++ b/libs/dsp @@ -0,0 +1 @@ +Subproject commit 389cc07574b3726a6c5ed5ee2217c7038e8fd993 diff --git a/libs/jambot b/libs/jambot new file mode 160000 index 0000000..ed127ac --- /dev/null +++ b/libs/jambot @@ -0,0 +1 @@ +Subproject commit ed127ac1ed84e4bfecb77b80f4afba084ab4ac69 diff --git a/libs/music b/libs/music new file mode 160000 index 0000000..85b6dc4 --- /dev/null +++ b/libs/music @@ -0,0 +1 @@ +Subproject commit 85b6dc43d74dbe41512e571c8cd7104c73697e80 diff --git a/libs/ninjam b/libs/ninjam new file mode 160000 index 0000000..94d032e --- /dev/null +++ b/libs/ninjam @@ -0,0 +1 @@ +Subproject commit 94d032eaeb924c27e5c5caaa503548248eadb008 diff --git a/scripts/trim_soundfont.py b/scripts/trim_soundfont.py new file mode 100644 index 0000000..d7aa8e1 --- /dev/null +++ b/scripts/trim_soundfont.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +"""Keep a handful of presets from a SoundFont and drop the rest. + +A packaging tool, not part of the plugin. It exists because a General MIDI bank +is 128 instruments and Antiphon wants perhaps twenty: the case for sampled +voices at all is narrow (`ROADMAP.md`), covering only what a physical model will +never do well -- an acoustic piano, a brass section, bowed strings, reeds. +Everything the band already plays sounds better modelled, and nobody needs the +helicopter. + +WHAT THIS SAVES, MEASURED, because the obvious guess is wrong. Dropping 92% of +GeneralUser GS's presets removes only 59% of its bytes. The sound effects are +cheap -- a fraction of a second each -- and the expensive presets are exactly +the ones worth keeping, because a convincing piano or string section is many +megabytes of multisampling. Trimming to a quarter of the presets gives about +40% of the size, not 25%. + +It compounds with SF3 though, and that is where it pays: 41% of the samples at +Ogg quality 0.8 (33%) is about 4 MB, from 31. + + python3 scripts/trim_soundfont.py in.sf2 out.sf2 --preset 0:0 --preset 0:48 + python3 scripts/trim_soundfont.py in.sf2 out.sf2 --set core + sf3convert -q 0.8 out.sf2 out.sf3 + +A SoundFont is a RIFF file whose `pdta` list is five parallel arrays chained by +index -- presets point into bags, bags into generators, generators at +instruments, instruments into their own bags and generators, and those at +samples. Removing anything means renumbering every chain that follows it, which +is the whole of the work here. The sample data itself is copied verbatim, so +this is lossless: it only ever removes. +""" + +import argparse +import struct +import sys + +# Generator operators we have to follow (SF2 spec section 8.1). +GEN_INSTRUMENT = 41 +GEN_SAMPLE_ID = 53 + +# The spec requires at least 46 zero sample-frames between samples so that an +# interpolating synth reading past a loop point cannot walk into its neighbour. +SAMPLE_PADDING = 46 + +# What Antiphon would actually use. Bank 0 programs, General MIDI numbering. +SETS = { + # One of each family, for a first experiment. + "minimal": [0, 24, 40, 48, 56, 65, 71, 73], + + # Everything a physical model will not do well, and nothing else. + "core": [0, 11, 12, 16, 19, 24, 25, 40, 42, 45, 48, 52, + 56, 57, 58, 60, 61, 64, 65, 66, 68, 71, 73], + + # `core` plus the instruments the band itself plays: five basses, the + # electric guitars, both electric pianos, harpsichord and clavinet. + # + # These overlap what the synthesis already does, and that is the point -- + # having both is how you find out which is better, and the answer is + # unlikely to be the same for a fingered bass as for a Rhodes. + "band": [0, 4, 5, 6, 7, 11, 12, 16, 19, 24, 25, 26, 27, 28, 29, 30, + 32, 33, 34, 35, 36, 37, 40, 42, 45, 48, 52, + 56, 57, 58, 60, 61, 64, 65, 66, 68, 71, 73], + + # Everything except the synthesisers (80-103), the sound effects (120-127) + # and the two synth basses. + # + # Worth knowing before choosing it: this is the set most people would name + # first, and it saves the LEAST. Synths and effects together are 11% of the + # bytes, because they are short and thin. All the weight is in the acoustic + # multisampling, which is exactly what any of these sets is keeping. + "acoustic": ([p for p in range(0, 38)] + [p for p in range(40, 80)] + + [p for p in range(104, 120)]), +} + +# Drum kits live in bank 128, and they are the bargain in this file. +# +# Each is about 2.7 MB on its own, but they SHARE almost everything -- the GS +# kits are largely one set of samples remapped, with a handful of kit-specific +# pieces. So the first costs 2.69 MB and the other seven cost 1.38 MB between +# them. Eight kits for barely more than one. +# +# Worth taking whole for a second reason. The modelled kit has three pieces -- +# kick, snare, hat -- and this is 65 samples per kit including five toms, ride, +# ride bell, crash, splash, china, cowbell, tambourine, claves, congas, bongos, +# timbales, agogo, guiro, cabasa, shaker, whistle and woodblock. None of that is +# a physical model we are ever going to write, and percussion one-shots are also +# the best case for Ogg compression, since nothing is looped. +# The acoustic ones. Electronic and 808/909 are dropped because the modelled kit +# is already a synthesised one and does that job better -- it varies with +# velocity and never repeats, which is what a drum machine sample cannot do. +# Room is dropped because the kit is put in a room of our own (BotDsp::Room), and +# baking a second one into the samples would be two rooms. +DRUM_KITS = [0, 16, 32, 40, 48] # Standard, Power, Jazz, Brush, Orchestral + +# All of them, including the electronic kits, for comparison. +DRUM_KITS_ALL = [0, 8, 16, 24, 25, 32, 40, 48] + + +def chunks(buf, start, end): + i = start + while i + 8 <= end: + cid = buf[i:i + 4].decode("latin1") + size = struct.unpack("= len(self.inst) - 1: + return used + for b in self.instrument_bags(instrument): + for g in self._gen_span(self.ibag, b, self.igen): + if self._u16(self.igen[g], 0) == GEN_SAMPLE_ID: + used.add(self._u16(self.igen[g], 2)) + return used + + def sample_span(self, s): + return struct.unpack(" {after:.2f} MB ({100 * after / before:.1f}%)") + + +if __name__ == "__main__": + main() diff --git a/src/AudioMeasure.h b/src/AudioMeasure.h new file mode 100644 index 0000000..49ec834 --- /dev/null +++ b/src/AudioMeasure.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +// Peak, rms, crest, dB, brightness, pitch and loudness live in +// `chalkwalk::dsp::measure` now. Nothing about them was ever specific to a +// Ninjam client: they are how you find out what a signal is, and the reason +// they had to move is that the ecosystem had grown three copies of `peak` and +// `rms` and two of `fundamentalHz` -- two pitch detectors, which is two +// answers to one question and no way to tell which is lying. +// +// An alias rather than a re-export list, because like `Harmony` and unlike +// `MusicalKey` there is nothing of Antiphon's to add: the whole of it moved. +// The old spelling is kept because the call sites read better for it -- what +// this repository measures is audio, and the library it comes from is dsp. +namespace AudioMeasure = chalkwalk::dsp::measure; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7203066..f4fe297 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -53,8 +53,8 @@ target_sources(Antiphon StandaloneApp.cpp PluginEditor.cpp NinjamClient.cpp - NinjamProtocol.cpp - IntervalClock.cpp + PracticeServer.cpp + PracticeRoom.cpp MetronomeVoice.cpp RemoteUserStrip.cpp RemoteChannelRow.cpp @@ -63,12 +63,9 @@ target_sources(Antiphon ChatFormat.cpp ClipsortLog.cpp SessionWriter.cpp - MusicalKey.cpp AccessibilityAudit.cpp ServerBrowserDialog.cpp ShortcutsDialog.cpp - Sha1.cpp - VorbisCodec.cpp ) clap_juce_extensions_plugin(TARGET Antiphon CLAP_ID "com.chalkwalk.antiphon" CLAP_FEATURES "audio-effect" "tool") @@ -84,6 +81,10 @@ target_compile_definitions(Antiphon target_link_libraries(Antiphon PRIVATE + chalkwalk::music + chalkwalk::dsp + chalkwalk::jambot + chalkwalk::ninjam antiphon_fonts juce::juce_audio_utils juce::juce_audio_plugin_client diff --git a/src/ChannelMix.h b/src/ChannelMix.h index db82818..22ffd58 100644 --- a/src/ChannelMix.h +++ b/src/ChannelMix.h @@ -1,106 +1,11 @@ #pragma once -#include - -// How a local channel's input becomes the pair of samples that get monitored, -// metered and transmitted. -// -// Split out of PluginProcessor because that file cannot be compiled into the -// test target (it needs the JucePlugin_* defines), and because the same three -// rules were previously written out three times -- in the capture path, the -// monitor mix and the peak meters -- and had drifted apart. "Mono" summed in -// none of them: it selected the left channel and silently discarded the right -// half of a stereo source, while the meters ignored the flag entirely and went -// on showing an independent stereo pair. - -namespace ChannelMix { - -struct Frame { - float left = 0.0f; - float right = 0.0f; -}; - -// Volume and pan, applied to both the monitor mix and the transmitted audio. -// Mute and solo are deliberately absent: they are monitor-only and must never -// change what other players hear. -inline Frame panGains(float volume, float pan) { - return {volume * (pan <= 0.0f ? 1.0f : 1.0f - pan), - volume * (pan >= 0.0f ? 1.0f : 1.0f + pan)}; -} - -// One frame of the channel's source, before gain. -// -// `srcR` is null when the assigned bus is itself mono, in which case the single -// channel feeds both sides. When `mono` is set on a stereo bus the two sides are -// summed and halved -- averaging rather than adding keeps a correlated stereo -// source at its original level instead of doubling it. -inline Frame sourceFrame(const float *srcL, const float *srcR, bool mono, - int index) { - if (srcL == nullptr) - return {}; - const float l = srcL[index]; - if (srcR == nullptr) - return {l, l}; - if (mono) { - const float summed = 0.5f * (l + srcR[index]); - return {summed, summed}; - } - return {l, srcR[index]}; -} - -// Peak of each side over `count` frames, measured on the post-mono signal so a -// mono channel reports the level it actually transmits. `gains` scales the -// result, so the meter shows what is heard. -inline Frame peaks(const float *srcL, const float *srcR, bool mono, int start, - int count, Frame gains) { - Frame p; - for (int i = 0; i < count; ++i) { - const Frame f = sourceFrame(srcL, srcR, mono, start + i); - p.left = std::max(p.left, std::abs(f.left)); - p.right = std::max(p.right, std::abs(f.right)); - } - p.left *= gains.left; - p.right *= gains.right; - return p; -} - -// Writes `count` gained frames into two destination pointers. Used for the -// transmit ring buffer, which is written in up to two segments. -// Writes the channel's contribution to the transmit ring. -// -// Deliberately un-gated: the ring stores what you played, and TransmitSpans -// records which parts of it you agreed to send, with the two combined at the -// interval boundary. -// -// This used to take a `transmitting` flag and write silence when it was false. -// That was the right behaviour in the wrong place -- gating at capture destroys -// the audio, so there was nothing left for the retroactive gesture to enable. +// Adopted from chalkwalk-ninjam (libs/ninjam, MIT). // -// Deliberately independent of mute and solo, which are monitor-only: what you -// hear and what you send are separate questions in both directions. -inline void write(float *dstL, float *dstR, const float *srcL, - const float *srcR, bool mono, int srcStart, int count, - Frame gains) { - for (int i = 0; i < count; ++i) { - const Frame f = sourceFrame(srcL, srcR, mono, srcStart + i); - if (dstL != nullptr) - dstL[i] = f.left * gains.left; - if (dstR != nullptr) - dstR[i] = f.right * gains.right; - } -} +// A namespace alias rather than a pile of using-declarations, because +// ChannelMix is a namespace of free functions and aliasing it keeps every +// `ChannelMix::` call site in this repository spelled exactly as it was. -// Adds `count` gained frames into two destination pointers, for the monitor mix -// where several channels sum into the same output bus. -inline void addInto(float *dstL, float *dstR, const float *srcL, - const float *srcR, bool mono, int count, Frame gains) { - for (int i = 0; i < count; ++i) { - const Frame f = sourceFrame(srcL, srcR, mono, i); - if (dstL != nullptr) - dstL[i] += f.left * gains.left; - if (dstR != nullptr) - dstR[i] += f.right * gains.right; - } -} +#include -} // namespace ChannelMix +namespace ChannelMix = chalkwalk::ninjam::channelmix; diff --git a/src/ChatFormat.cpp b/src/ChatFormat.cpp index 5e6618f..01f050c 100644 --- a/src/ChatFormat.cpp +++ b/src/ChatFormat.cpp @@ -1,5 +1,6 @@ #include "ChatFormat.h" +#include "Harmony.h" #include "MusicalKey.h" namespace ChatFormat { @@ -16,9 +17,10 @@ Line render(const juce::String &type, const juce::String &username, return out; } - // A key announcement is recognised by its tag wherever it came from, so the - // same line works whether it was typed as chat or left in the topic. - if (MusicalKey::parseTagged(text).valid) { + // A key announcement is recognised wherever it came from, so the same line + // works whether it was typed as chat or left in the topic -- and in either of + // the two forms, since `/key G minor` is what a bot can actually say. + if (MusicalKey::parseAnnouncement(text.toStdString()).valid) { out.category = Category::Key; out.text = "~~ " + text; return out; @@ -76,35 +78,11 @@ Line render(const juce::String &type, const juce::String &username, } bool isChordProgression(const juce::String &text) { - const auto trimmed = text.trim(); - if (!trimmed.startsWithChar('|')) - return false; - - // At least two measures with something in them. One "|C" is a chord, not a - // progression, and requiring two is what keeps a stray pipe out. - int measures = 0; - bool anyChord = false; - for (const auto &part : juce::StringArray::fromTokens(trimmed, "|", "")) { - const auto measure = part.trim(); - if (measure.isEmpty()) - continue; - ++measures; - for (const auto &token : - juce::StringArray::fromTokens(measure, " \t", "")) { - const auto chord = token.trim(); - if (chord.isEmpty()) - continue; - // A chord starts with a note letter, optionally with an accidental. The - // rest -- m, 7, maj7, sus4, /G -- is not worth validating: this only - // decides how to colour a line. - const auto letter = juce::CharacterFunctions::toUpperCase(chord[0]); - if (letter < 'A' || letter > 'G') - return false; // a word in the middle means this is prose with pipes - anyChord = true; - } - } - - return measures >= 2 && anyChord; + // Deliberately not a second parser. This was one once -- it validated the + // first letter and shrugged at the rest -- so a line could be coloured as a + // chart here and rejected by the band, or the other way round. One tokeniser + // decides both (`PRINCIPLES §8`). + return Harmony::looksLikeChart(text.toStdString()); } VoteState parseVote(const juce::String &text) { diff --git a/src/ChatFormat.h b/src/ChatFormat.h index 3d6f542..811b52a 100644 --- a/src/ChatFormat.h +++ b/src/ChatFormat.h @@ -1,5 +1,6 @@ #pragma once +#include #include // What a chat line is, and what the voting system is saying. @@ -54,6 +55,30 @@ struct VoteState { // Returns valid == false for any line that is not from the voting system. VoteState parseVote(const juce::String &text); +// What the server will accept, and it is NOT one range: the vote path and the +// admin path have different limits, in both the reference server and libninjam. +// +// !vote bpm|bpi N 40..400 BPM, 2..64 BPI (usercon.h MIN_/MAX_BPM/BPI) +// /bpm N, /bpi N 20..400 BPM, 2..1024 BPI (usercon.cpp, PRIV_BPM only) +// +// The asymmetry is worth the two predicates because the failure is silent and +// confusing: an out-of-range `!vote` does not say what was wrong with it, it +// answers "!vote requires parameters" as though the command +// had been malformed (justinfrankel/ninjam server/usercon.cpp:1169-1186). +// A DAW sitting at 30 BPM is entirely ordinary, so offering that vote and +// letting the server reject it is a dead end a player cannot diagnose. +// +// These bound what we SEND. They must never be used to filter what we receive: +// an admin can set a BPI of 124 and every client, ours included, has to follow +// it -- which is exactly what a server does when asked (see docs/PROTOCOL.md). +// The stock server's limits, now in `chalkwalk::ninjam::conventions` because a +// bot refusing an impossible vote needs them as much as the UI does. Re-exported +// under the names this project already uses. +using chalkwalk::ninjam::conventions::isAdminSettableBpi; +using chalkwalk::ninjam::conventions::isAdminSettableBpm; +using chalkwalk::ninjam::conventions::isVotableBpi; +using chalkwalk::ninjam::conventions::isVotableBpm; + // Whether a line is a chord progression in the convention Jamtaba established: // measures separated by bars, as in "| Dm7 | G7 | Bb | Am7". // diff --git a/src/Harmony.h b/src/Harmony.h new file mode 100644 index 0000000..b2bfdab --- /dev/null +++ b/src/Harmony.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +// Chords, charts, degrees, roman numerals, voice leading and key inference all +// live in `chalkwalk::music::Harmony` now: they are music theory, and the bots +// need them as much as the chat UI does. +// +// An alias rather than a re-export list, because unlike `MusicalKey` there is +// nothing of Antiphon's to add here -- the whole of it moved. +namespace Harmony = chalkwalk::music::Harmony; diff --git a/src/IntervalClock.cpp b/src/IntervalClock.cpp deleted file mode 100644 index bb84020..0000000 --- a/src/IntervalClock.cpp +++ /dev/null @@ -1,159 +0,0 @@ -#include "IntervalClock.h" - -#include -#include - -void IntervalClock::prepare(double sr) { - sampleRate = sr > 0.0 ? sr : 0.0; - recomputeGrid(); - reset(); -} - -void IntervalClock::setTempo(int newBpm, int newBpi) { - if (newBpm <= 0 || newBpi <= 0) - return; - pendingBpm = newBpm; - pendingBpi = newBpi; - if (atIntervalStart) { - bpm = pendingBpm; - bpi = pendingBpi; - recomputeGrid(); - } -} - -void IntervalClock::reset() { - posInInterval = 0; - nextBeat = 0; - atIntervalStart = true; - if (bpm != pendingBpm || bpi != pendingBpi) { - bpm = pendingBpm; - bpi = pendingBpi; - recomputeGrid(); - } -} - -void IntervalClock::recomputeGrid() { - beatOffsets.clear(); - intervalSamples = 0; - if (sampleRate <= 0.0 || bpm <= 0 || bpi <= 0) - return; - - // Deliberately identical arithmetic to the reference client - // (justinfrankel/ninjam njclient.cpp:794-810): samples per interval is - // truncated, not rounded, and the beat grid is a whole number of samples - // obtained by integer division. Matching this keeps our interval boundaries - // aligned with every other Ninjam client on the server. - const double v = (double)bpi / ((double)bpm * (1.0 / 60.0)) * sampleRate; - intervalSamples = (int)v; - - // Degenerate tempos (absurdly high bpm at a low sample rate) would otherwise - // give a zero-length interval and spin forever in advance(). - if (intervalSamples < bpi) - intervalSamples = bpi; - - // Beats are placed by rounding rather than by njclient's integer division - // (:810), which accumulates most of a sample of error per beat. Only the - // interval length has to match the reference exactly -- that is what other - // clients see. Beat offsets drive the local click and the UI, so they may as - // well be sample-accurate. They restart from the boundary every interval, so - // nothing accumulates across intervals either way. - beatOffsets.reserve((size_t)bpi); - for (int i = 0; i < bpi; ++i) - beatOffsets.push_back( - (int)std::llround((double)intervalSamples * (double)i / (double)bpi)); - - for (int i = 1; i < bpi; ++i) - if (beatOffsets[(size_t)i] <= beatOffsets[(size_t)i - 1]) - beatOffsets[(size_t)i] = beatOffsets[(size_t)i - 1] + 1; -} - -int IntervalClock::beatStartSample(int beatIndex) const { - if (beatIndex < 0 || beatIndex >= (int)beatOffsets.size()) - return -1; - return beatOffsets[(size_t)beatIndex]; -} - -double IntervalClock::phaseBeats() const { - if (intervalSamples <= 0) - return 0.0; - return (double)posInInterval / (double)intervalSamples * (double)bpi; -} - -int IntervalClock::currentBeat() const { - if (beatOffsets.empty()) - return 0; - return nextBeat > 0 ? nextBeat - 1 : bpi - 1; -} - -void IntervalClock::splitAtIntervalStarts(const std::vector &events, - int numSamples, - std::vector &out) { - out.clear(); - if (numSamples <= 0) - return; - - int cursor = 0; - for (const auto &e : events) { - if (e.type != Event::Type::IntervalStart) - continue; - const int at = - e.sampleOffset < 0 - ? 0 - : (e.sampleOffset > numSamples ? numSamples : e.sampleOffset); - if (at < cursor) - continue; // events are ordered; defensive - // A zero-length piece is still emitted: the interval may have been - // completed by earlier blocks, and the boundary must still fire. - out.push_back({cursor, at - cursor, true}); - cursor = at; - } - if (cursor < numSamples) - out.push_back({cursor, numSamples - cursor, false}); -} - -void IntervalClock::advance(int numSamples, std::vector &out) { - if (numSamples <= 0 || !isValid()) - return; - - int consumed = 0; - while (consumed < numSamples) { - if (atIntervalStart) { - // Apply any tempo change queued during the previous interval. - if (bpm != pendingBpm || bpi != pendingBpi) { - bpm = pendingBpm; - bpi = pendingBpi; - recomputeGrid(); - if (!isValid()) - return; - } - out.push_back({Event::Type::IntervalStart, consumed, 0}); - atIntervalStart = false; - } - - // Emit every beat whose start lies at or before the current position. - while (nextBeat < bpi && - beatOffsets[(size_t)nextBeat] <= (int)posInInterval) { - out.push_back({Event::Type::Beat, consumed, nextBeat}); - ++nextBeat; - } - - // Advance to whichever comes first: the next beat, the end of the - // interval, or the end of the block. - const int64_t nextEdge = (nextBeat < bpi) - ? (int64_t)beatOffsets[(size_t)nextBeat] - : (int64_t)intervalSamples; - const int step = - (int)std::min(nextEdge - posInInterval, numSamples - consumed); - if (step <= 0) - break; // defensive; recomputeGrid guarantees a strictly increasing grid - - posInInterval += step; - consumed += step; - - if (posInInterval >= intervalSamples) { - posInInterval = 0; - nextBeat = 0; - atIntervalStart = true; - } - } -} diff --git a/src/IntervalClock.h b/src/IntervalClock.h index 02d6f8a..f84cb8c 100644 --- a/src/IntervalClock.h +++ b/src/IntervalClock.h @@ -1,92 +1,11 @@ #pragma once -#include -#include - -// Sample-exact beat and interval clock. -// -// Pure and deterministic: given (sampleRate, bpm, bpi) and a sequence of -// advance() calls, the emitted event stream is fully determined and does not -// depend on how the samples are divided into blocks. No JUCE, no allocation -// inside advance(). +// Adopted from chalkwalk-ninjam (libs/ninjam, MIT). // -// The grid is integer: samplesPerInterval() is computed once per tempo change, -// so every interval is exactly the same length. The previous implementation -// accumulated a double phase and wrapped it by subtraction, which left a -// residual uniformly distributed in [0, beatsPerSample) and made the interval -// boundary walk by a sample from interval to interval. That jitter propagated -// straight into the length of each transmitted interval. - -class IntervalClock { -public: - struct Event { - enum class Type { IntervalStart, Beat }; - Type type; - int sampleOffset; // index within the block passed to advance() - int beatIndex; // 0 .. bpi-1; IntervalStart always carries 0 - }; - - void prepare(double sampleRate); - - // Takes effect at the start of the next interval boundary; the current - // interval always plays out at its original length. Ignored if either value - // is not positive. - void setTempo(int bpm, int bpi); - - // Returns to the top of an interval. The next advance() emits IntervalStart - // (and Beat 0) at sample offset 0. - void reset(); - - // Appends events in ascending sampleOffset order. At an interval boundary - // both IntervalStart and Beat{0} are emitted, IntervalStart first. - void advance(int numSamples, std::vector &out); - - int samplesPerInterval() const { return intervalSamples; } - int64_t samplePosInInterval() const { return posInInterval; } - - // Exact start sample of the given beat within the interval, or -1 if out of - // range. - int beatStartSample(int beatIndex) const; - - // Position within the interval expressed in beats, 0 .. bpi. Drives the UI - // phase bar. - double phaseBeats() const; - - int currentBeat() const; - int getBpm() const { return bpm; } - int getBpi() const { return bpi; } - bool isValid() const { return intervalSamples > 0; } - - // One contiguous piece of a processBlock buffer, split at interval - // boundaries: [start, start + count). closesInterval is true when the piece - // ends exactly on a boundary, i.e. it completes the interval in progress. - // - // Capture has to be split this way or the transmitted interval is rounded to - // a whole number of blocks. Measured against the reference client that was - // about +1.3 ms of stretch at every interval seam (work item #27). - struct BlockSegment { - int start = 0; - int count = 0; - bool closesInterval = false; - }; - - // Pure: depends only on the event list, so it is unit-tested directly. - static void splitAtIntervalStarts(const std::vector &events, - int numSamples, - std::vector &out); - -private: - void recomputeGrid(); +// The reasoning about why the beat grid is precomputed per interval rather +// than accumulated -- which is the whole point of the class -- travelled with +// the code and is in the library header. - double sampleRate = 0.0; - int bpm = 120; - int bpi = 16; - int pendingBpm = 120; - int pendingBpi = 16; +#include - int intervalSamples = 0; - int64_t posInInterval = 0; - int nextBeat = 0; - bool atIntervalStart = true; - std::vector beatOffsets; // size bpi; beatOffsets[i] = start of beat i -}; +using chalkwalk::ninjam::IntervalClock; diff --git a/src/MusicalKey.cpp b/src/MusicalKey.cpp deleted file mode 100644 index 10fb3b0..0000000 --- a/src/MusicalKey.cpp +++ /dev/null @@ -1,241 +0,0 @@ -#include "MusicalKey.h" - -namespace MusicalKey { - -namespace { - -struct ModeName { - const char *name; - Mode mode; -}; - -// Longest-first within each family does not matter here because the whole -// remainder of the string is matched, not a prefix. -const ModeName kModeNames[] = { - {"major", Mode::Major}, {"maj", Mode::Major}, - {"minor", Mode::Minor}, {"min", Mode::Minor}, - {"m", Mode::Minor}, {"ionian", Mode::Ionian}, - {"dorian", Mode::Dorian}, {"phrygian", Mode::Phrygian}, - {"lydian", Mode::Lydian}, {"mixolydian", Mode::Mixolydian}, - {"mixo", Mode::Mixolydian}, {"aeolian", Mode::Aeolian}, - {"locrian", Mode::Locrian}, -}; - -// Semitones above C for the natural notes. -int naturalSemitone(juce_wchar letter) { - switch (letter) { - case 'C': - return 0; - case 'D': - return 2; - case 'E': - return 4; - case 'F': - return 5; - case 'G': - return 7; - case 'A': - return 9; - case 'B': - return 11; - default: - return -1; - } -} - -// Steps of each mode from its tonic. Major and Ionian coincide, as do Minor and -// Aeolian -- they are kept separate only so the name you typed comes back. -const int *modeSteps(Mode mode) { - static const int major[] = {0, 2, 4, 5, 7, 9, 11}; - static const int dorian[] = {0, 2, 3, 5, 7, 9, 10}; - static const int phrygian[] = {0, 1, 3, 5, 7, 8, 10}; - static const int lydian[] = {0, 2, 4, 6, 7, 9, 11}; - static const int mixolydian[] = {0, 2, 4, 5, 7, 9, 10}; - static const int aeolian[] = {0, 2, 3, 5, 7, 8, 10}; - static const int locrian[] = {0, 1, 3, 5, 6, 8, 10}; - - switch (mode) { - case Mode::Major: - case Mode::Ionian: - return major; - case Mode::Minor: - case Mode::Aeolian: - return aeolian; - case Mode::Dorian: - return dorian; - case Mode::Phrygian: - return phrygian; - case Mode::Lydian: - return lydian; - case Mode::Mixolydian: - return mixolydian; - case Mode::Locrian: - return locrian; - } - return major; -} - -// How many semitones above its relative major each mode's tonic sits. -// Indexed by the Mode enum. -const int kModeOffsetFromRelativeMajor[] = { - 0, // Major - 9, // Minor - 0, // Ionian - 2, // Dorian - 4, // Phrygian - 5, // Lydian - 7, // Mixolydian - 9, // Aeolian - 11, // Locrian -}; - -// Whether this key is conventionally written with flats. -// -// The spelling belongs to the key signature, not to how the tonic happened to -// be typed: D minor has one flat, so its sixth is Bb and never A#, even though -// nobody writes an accidental when they type "Dm". Derived from the relative -// major, which is what carries the signature. -bool usesFlats(int tonic, Mode mode) { - const int offset = kModeOffsetFromRelativeMajor[(int)mode]; - const int relativeMajor = (((tonic - offset) % 12) + 12) % 12; - // The major keys written with flats: F, Bb, Eb, Ab, Db. Everything else takes - // sharps, including the enharmonic toss-ups, where F# is the usual choice. - return relativeMajor == 5 || relativeMajor == 10 || relativeMajor == 3 || - relativeMajor == 8 || relativeMajor == 1; -} - -const char *kSharpNames[] = {"C", "C#", "D", "D#", "E", "F", - "F#", "G", "G#", "A", "A#", "B"}; -const char *kFlatNames[] = {"C", "Db", "D", "Eb", "E", "F", - "Gb", "G", "Ab", "A", "Bb", "B"}; - -juce::String noteName(int semitone, bool flat) { - const int s = ((semitone % 12) + 12) % 12; - return flat ? kFlatNames[s] : kSharpNames[s]; -} - -} // namespace - -juce::String modeName(Mode mode) { - switch (mode) { - case Mode::Major: - return "major"; - case Mode::Minor: - return "minor"; - case Mode::Ionian: - return "Ionian"; - case Mode::Dorian: - return "Dorian"; - case Mode::Phrygian: - return "Phrygian"; - case Mode::Lydian: - return "Lydian"; - case Mode::Mixolydian: - return "Mixolydian"; - case Mode::Aeolian: - return "Aeolian"; - case Mode::Locrian: - return "Locrian"; - } - return "major"; -} - -Key parseName(const juce::String &text) { - Key key; - const auto trimmed = text.trim(); - if (trimmed.isEmpty()) - return key; - - // Tonic letter, upper or lower case. - const auto letter = juce::CharacterFunctions::toUpperCase(trimmed[0]); - const int natural = naturalSemitone(letter); - if (natural < 0) - return key; - - int pos = 1; - int semitone = natural; - bool explicitFlat = false; - bool explicitSharp = false; - if (pos < trimmed.length()) { - const auto accidental = trimmed[pos]; - if (accidental == '#') { - semitone += 1; - explicitSharp = true; - ++pos; - } else if (accidental == 'b' || accidental == 'B') { - // Safe to take unconditionally: no mode name begins with "b", so a "b" - // in second position can only be a flat. "Bb" is B flat major, "Bbm" is - // B flat minor, and "Bm" never reaches here because "m" is not "b". - semitone -= 1; - explicitFlat = true; - ++pos; - } - } - - // An accidental the user typed wins for the tonic, so "Bb" comes back as - // "Bb"; otherwise the signature decides. - auto resolveSpelling = [&](Mode mode) { - key.flat = explicitFlat || - (!explicitSharp && usesFlats(((semitone % 12) + 12) % 12, mode)); - }; - - auto rest = trimmed.substring(pos).trim().toLowerCase(); - // An empty mode means major, so "D" is D major and "Bb" is B flat major. - if (rest.isEmpty()) { - key.valid = true; - key.tonic = ((semitone % 12) + 12) % 12; - key.mode = Mode::Major; - resolveSpelling(key.mode); - return key; - } - - for (const auto &entry : kModeNames) { - if (rest == entry.name) { - key.valid = true; - key.tonic = ((semitone % 12) + 12) % 12; - key.mode = entry.mode; - resolveSpelling(key.mode); - return key; - } - } - - return key; // a mode we do not recognise is not a key -} - -Key parseTagged(const juce::String &text) { - const int open = text.indexOfIgnoreCase(tagPrefix()); - if (open < 0) - return {}; - - const int contentStart = open + tagPrefix().length(); - const int close = text.indexOfChar(contentStart, ']'); - if (close < 0) - return {}; - - return parseName(text.substring(contentStart, close)); -} - -juce::String buildTagged(const Key &key) { - if (!key.valid) - return {}; - return "[key: " + displayName(key) + "]"; -} - -juce::String displayName(const Key &key) { - if (!key.valid) - return {}; - return noteName(key.tonic, key.flat) + " " + modeName(key.mode); -} - -juce::String scaleNotes(const Key &key) { - if (!key.valid) - return {}; - - const int *steps = modeSteps(key.mode); - juce::StringArray notes; - for (int i = 0; i < 7; ++i) - notes.add(noteName(key.tonic + steps[i], key.flat)); - return notes.joinIntoString(" "); -} - -} // namespace MusicalKey diff --git a/src/MusicalKey.h b/src/MusicalKey.h index b267c54..f347704 100644 --- a/src/MusicalKey.h +++ b/src/MusicalKey.h @@ -1,79 +1,78 @@ #pragma once -#include +#include +#include -// The key a jam is in: a tonic and a mode. -// -// Ninjam has no field for this -- the protocol carries audio, chat and tempo and -// nothing else. So the key travels as an ordinary chat message in a tagged form, -// `[key: D minor]`, which every other client shows as plain text and which we -// parse for display. That is the same shape Jamtaba uses for chord progressions, -// and it clears all three fences in NON-GOALS.md by construction: it needs no -// protocol extension, no cooperation from other clients, and nothing to change -// on the server. +#include + +// The key a jam is in, and how it travels. // -// Parsed ONLY from that tagged form, never from free chat text. Jamtaba's chord -// parser treats "I" and "l" as measure separators and consequently reads -// "I AM TIRED ..." as a chord progression -- that is a real entry in their test -// suite (elieserdejesus/JamTaba, -// tests/auto/chords/TestChatChordsProgressionParser.cpp). -// Guessing at prose is how you get a header that lies. +// The KEY ITSELF -- a tonic, a mode, how to spell it, how to read "D minor" +// and write it back -- lives in `chalkwalk::music::Notation`, because it is +// music theory and two projects need it. This header re-exports it under the +// name every call site here already uses, and adds the one part that is NOT +// theory and must never go to a music library: the wire form. // -// JUCE-light and free of juce_gui_basics, so it is unit-testable in the headless -// test target -- PluginEditor cannot be compiled there at all. - +// Ninjam has no field for a key. The protocol carries audio, chat and tempo and +// nothing else. So the key travels as an ordinary chat message in a tagged +// form, `[key: D minor]`, which every other client shows as plain text and +// which we parse for display. That is the same shape Jamtaba uses for chord +// progressions, and it clears all three fences in NON-GOALS.md by +// construction: it needs no protocol change, no server change, and no +// agreement from anybody else in the room. namespace MusicalKey { -// The seven diatonic modes plus the two everyone actually says. Major and Minor -// are kept distinct from Ionian and Aeolian even though they are the same -// scale: someone who typed "D minor" should see "D minor" back, not "D Aeolian". -enum class Mode { - Major, - Minor, - Ionian, - Dorian, - Phrygian, - Lydian, - Mixolydian, - Aeolian, - Locrian -}; +// Re-exported from the music library. Named individually rather than with a +// namespace alias, because this namespace also holds the tag below -- and +// because the list is then an honest statement of what Antiphon takes. +using chalkwalk::music::Notation::Key; +using chalkwalk::music::Notation::kScaleDegrees; +using chalkwalk::music::Notation::Mode; -struct Key { - bool valid = false; - int tonic = 0; // semitones above C, 0-11 - bool flat = false; // spell the tonic with a flat rather than a sharp - Mode mode = Mode::Major; +using chalkwalk::music::Notation::degreeToMidi; +using chalkwalk::music::Notation::displayName; +using chalkwalk::music::Notation::modeName; +using chalkwalk::music::Notation::noteName; +using chalkwalk::music::Notation::parseName; +using chalkwalk::music::Notation::scaleNotes; +using chalkwalk::music::Notation::scaleSteps; +using chalkwalk::music::Notation::usesFlats; - bool operator==(const Key &o) const { - return valid == o.valid && tonic == o.tonic && mode == o.mode; - } - bool operator!=(const Key &o) const { return !(*this == o); } -}; - -// The tag a key travels in. Chosen to be unmistakable in a chat log and still -// readable to someone whose client knows nothing about it. -inline juce::String tagPrefix() { return "[key:"; } - -// "D minor", "F# Dorian", "Bb major". Returns an invalid Key for anything else. -Key parseName(const juce::String &text); +// --------------------------------------------------------------------------- +// The tag, which is a NINJAM room convention rather than music theory. +// +// The ENVELOPE -- the brackets, the line-leading slash, what a `!vote` will +// take -- is `chalkwalk::ninjam::conventions`, because the bots need it too and +// neither project is beneath the other. What is left here is the three-line +// composition of envelope and notation, which is glue rather than knowledge: +// the convention itself is single-sourced. -// Pulls a key out of a chat line or a topic, i.e. finds `[key: ...]` anywhere in -// the string and parses what is inside. Returns an invalid Key when the tag is -// absent -- deliberately, so ordinary chat can never set the key. -Key parseTagged(const juce::String &text); +inline std::string tagPrefix() { + return chalkwalk::ninjam::conventions::keyTagPrefix(); +} -// The message `/key Dm` sends: "[key: D minor]". -juce::String buildTagged(const Key &key); +// `[key: D minor]` anywhere in a line. +inline Key parseTagged(const std::string &text) { + return parseName(chalkwalk::ninjam::conventions::extractKeyTag(text)); +} -// "D minor". Empty for an invalid key. -juce::String displayName(const Key &key); +// The line to send. Only this form sets the key. +inline std::string buildTagged(const Key &key) { + if (!key.valid) + return {}; + return chalkwalk::ninjam::conventions::buildKeyTag(displayName(key)); +} -// The notes of the scale, spelled to match the tonic: "D E F G A Bb C". -// Empty for an invalid key. Useful spoken as well as shown -- a player who -// cannot see the header still gets the one fact they need. -juce::String scaleNotes(const Key &key); +// A key from a chat line: the tag anywhere, or a line-leading `/key`. +inline Key parseAnnouncement(const std::string &line) { + return parseName( + chalkwalk::ninjam::conventions::extractKeyAnnouncement(line)); +} -juce::String modeName(Mode mode); +// What a bot should tell somebody to type. Deliberately NOT the tag, because +// saying the tag sets the key. +inline std::string announcementAdvice(const Key &key) { + return chalkwalk::ninjam::conventions::keyAdviceLine(displayName(key)); +} } // namespace MusicalKey diff --git a/src/NinjamBotClient.h b/src/NinjamBotClient.h new file mode 100644 index 0000000..da92c45 --- /dev/null +++ b/src/NinjamBotClient.h @@ -0,0 +1,174 @@ +#pragma once + +#include "NinjamClient.h" +#include + +#include + +// Antiphon's `NinjamClient`, as the bots see it. +// +// The whole of what ties the band to this plugin's client, and it is one class +// with no logic in it: names, types and the direction of a callback. Everything +// a bot decides is on the other side of the interface, where it can be moved +// and tested without a socket. +// +// The conversions are all this does, and they are not incidental -- they are +// the JUCE boundary. `juce::String` in, `std::string` out, and an +// `AudioBuffer` built around the caller's pointers rather than copied. +class NinjamBotClient final : public BotClient::Client, + private NinjamClientListener { +public: + NinjamBotClient() { client.addListener(this); } + + ~NinjamBotClient() override { + client.removeListener(this); + client.disconnectFromServer(); + } + + void addListener(BotClient::Listener *l) override { listeners.push_back(l); } + void removeListener(BotClient::Listener *l) override { + listeners.erase(std::remove(listeners.begin(), listeners.end(), l), + listeners.end()); + } + + void setSampleRate(double rate) override { client.setSampleRate(rate); } + + void setChannels(const std::vector &names) override { + juce::StringArray out; + for (const auto &n : names) + out.add(juce::String(n)); + client.updateChannelInfo(out); + } + + void setDefaultRecvEnabled(bool enabled) override { + client.setDefaultRecvEnabled(enabled); + } + + void connect(const std::string &host, int port, const std::string &username, + const std::string &password) override { + client.connectToServer(juce::String(host), port, juce::String(username), + juce::String(password)); + } + + void disconnect() override { client.disconnectFromServer(); } + bool isConnected() const override { return client.isConnected(); } + + std::vector members() const override { + std::vector out; + for (const auto &m : client.getRoomMembers()) + out.push_back({m.username.toStdString(), m.channelCount}); + return out; + } + + std::vector peers() const override { + std::vector out; + for (const auto &[name, user] : client.getRemoteUsers()) { + BotClient::Peer peer; + peer.username = name.toStdString(); + for (const auto &[index, channel] : user.channels) + peer.channels.push_back( + {index, channel.channelName.toStdString(), channel.recvEnabled}); + out.push_back(std::move(peer)); + } + return out; + } + + void setRecv(const std::string &username, int channelIndex, + bool enabled) override { + client.setRemoteUserRecv(juce::String(username), channelIndex, enabled); + } + + void sendChat(const std::string &text) override { + client.sendChatMessage(juce::String(text)); + } + + void sendPrivate(const std::string &to, const std::string &text) override { + client.sendPrivateMessage(juce::String(to), juce::String(text)); + } + + std::unique_ptr + createTimer(std::function onFire) override { + return std::make_unique(std::move(onFire)); + } + + void transmit(const float *left, const float *right, + int numSamples) override { + if (left == nullptr || numSamples <= 0) + return; + // Wrapped rather than copied: the caller already owns this memory for the + // duration of the call, and an interval is several seconds of audio. + float *channels[2] = {const_cast(left), + const_cast(right != nullptr ? right : left)}; + juce::AudioBuffer view(channels, right != nullptr ? 2 : 1, + numSamples); + client.processCapturedAudio(view, numSamples, 0, false); + } + +private: + // A juce::Timer is the message thread's own, which is where NinjamClient + // delivers every callback -- so a bot's timers and its messages stay on one + // thread, exactly as they were before the interface existed. + class MessageThreadTimer final : public BotClient::Timer, + private juce::Timer { + public: + explicit MessageThreadTimer(std::function fn) + : onFire(std::move(fn)) {} + ~MessageThreadTimer() override { stopTimer(); } + + void start(int delayMs) override { startTimer(delayMs); } + void stop() override { stopTimer(); } + bool isRunning() const override { return isTimerRunning(); } + + private: + void timerCallback() override { + // One-shot: stop before firing, so a callback that starts it again wins + // rather than being cancelled by its own return. + stopTimer(); + if (onFire) + onFire(); + } + std::function onFire; + }; + + // NinjamClient calls these; the bots hear the versions above. + void onConnected() override { + each([](auto *l) { l->onConnected(); }); + } + + void onDisconnected(const juce::String &reason) override { + const auto why = reason.toStdString(); + each([&](auto *l) { l->onDisconnected(why); }); + } + + void onServerConfig(int bpm, int bpi) override { + each([&](auto *l) { l->onServerConfig(bpm, bpi); }); + } + + void onUserInfoChange() override { + each([](auto *l) { l->onUserInfoChange(); }); + } + + void onRoomMembershipChange(const juce::String &username, + bool joined) override { + const auto who = username.toStdString(); + each([&](auto *l) { l->onRoomMembershipChange(who, joined); }); + } + + void onChatMessage(const juce::String &type, const juce::String &username, + const juce::String &text) override { + const auto t = type.toStdString(), u = username.toStdString(), + m = text.toStdString(); + each([&](auto *l) { l->onChatMessage(t, u, m); }); + } + + template void each(Fn fn) { + // A copy, because a listener may remove itself while being called -- which + // is exactly what a bot does when it is told to leave. + const auto snapshot = listeners; + for (auto *l : snapshot) + fn(l); + } + + NinjamClient client; + std::vector listeners; +}; diff --git a/src/NinjamClient.cpp b/src/NinjamClient.cpp index 4c523e4..ee7d352 100644 --- a/src/NinjamClient.cpp +++ b/src/NinjamClient.cpp @@ -262,10 +262,10 @@ void NinjamClient::run() { if (!NinjamProtocol::readFrameHeader(header, frame)) break; - juce::MemoryBlock payload; + ByteBuffer payload; if (frame.length > 0) { - payload.setSize(frame.length, true); - if (!readFull(payload.getData(), static_cast(frame.length))) + payload.resize(frame.length); + if (!readFull(payload.data(), static_cast(frame.length))) break; } @@ -274,8 +274,21 @@ void NinjamClient::run() { } connectionState = 0; - if (socket) - socket->close(); + { + // Under writeMutex, because writeFull is inside ::send on another thread + // often enough to matter. Closing the descriptor out from under a writer is + // a race on the file descriptor itself: the fd number can be reused by + // anything that opens a file next, so the write lands somewhere else + // entirely. Found by TSan once a practice room had several clients coming + // and going in one process; before that nothing wrote from another thread + // at the moment of teardown often enough to catch it. + // + // The mutex is a leaf -- writeFull takes nothing else -- so this cannot + // deadlock, and the wait is bounded by one socket write. + juce::ScopedLock sl(writeMutex); + if (socket) + socket->close(); + } // Drop all per-session state. Without this a reconnect shows the previous // session's users, and their orphaned channel streams keep being swapped @@ -316,8 +329,7 @@ void NinjamClient::run() { }); } -bool NinjamClient::handleMessage(juce::uint8 type, - const juce::MemoryBlock &payload) { +bool NinjamClient::handleMessage(juce::uint8 type, const ByteBuffer &payload) { // A malformed message is dropped rather than treated as fatal: the framing // layer already resynchronised, so the connection stays usable. auto malformed = [type]() { @@ -386,10 +398,11 @@ bool NinjamClient::handleMessage(juce::uint8 type, RemoteUserChannel newChan; newChan.channelIndex = e.channelIndex; newChan.channelName = e.channelName; + newChan.recvEnabled = defaultRecvEnabled.load(); user.channels[e.channelIndex] = newChan; changed = true; } else if (user.channels[e.channelIndex].channelName != - e.channelName) { + juce::String(e.channelName)) { user.channels[e.channelIndex].channelName = e.channelName; changed = true; } @@ -437,7 +450,7 @@ bool NinjamClient::handleMessage(juce::uint8 type, const int slotIndex = acquireStreamSlot(begin.username, begin.channelIndex); if (slotIndex < 0) { juce::Logger::writeToLog("[rx] no free stream slot for " + - begin.username + " channel " + + juce::String(begin.username) + " channel " + juce::String(begin.channelIndex)); return true; } @@ -628,9 +641,15 @@ bool NinjamClient::handleMessage(juce::uint8 type, if (!NinjamProtocol::parseChat(payload, parsed)) return malformed(); - if (parsed.type.isNotEmpty()) { + if (!parsed.type.empty()) { ChatMessage msg; msg.type = parsed.type; + + // Captured here and delivered with the callback below, because the room + // member set is updated on this thread and read on another one. See + // NinjamClientListener::onRoomMembershipChange. + juce::String membershipChanged; + bool membershipJoined = false; if (msg.type == "MSG" || msg.type == "PRIVMSG") { msg.username = parsed.p1; msg.text = parsed.p2; @@ -640,16 +659,24 @@ bool NinjamClient::handleMessage(juce::uint8 type, } else if (msg.type == "JOIN") { msg.username = "Server"; msg.text = parsed.p1 + " joined"; - if (parsed.p1.isNotEmpty()) { - juce::ScopedLock sl(usersMutex); - roomMembers.insert(parsed.p1); + if (!parsed.p1.empty()) { + { + juce::ScopedLock sl(usersMutex); + roomMembers.insert(parsed.p1); + } + membershipChanged = parsed.p1; + membershipJoined = true; } } else if (msg.type == "PART") { msg.username = "Server"; msg.text = parsed.p1 + " left"; - if (parsed.p1.isNotEmpty()) { - juce::ScopedLock sl(usersMutex); - roomMembers.erase(parsed.p1); + if (!parsed.p1.empty()) { + { + juce::ScopedLock sl(usersMutex); + roomMembers.erase(parsed.p1); + } + membershipChanged = parsed.p1; + membershipJoined = false; } } else { msg.username = "Server"; @@ -664,7 +691,11 @@ bool NinjamClient::handleMessage(juce::uint8 type, } callAsyncIfAlive([this, type = msg.type, user = msg.username, - text = msg.text]() { + text = msg.text, who = membershipChanged, + joined = membershipJoined]() { + if (who.isNotEmpty()) + listeners.call(&NinjamClientListener::onRoomMembershipChange, who, + joined); listeners.call(&NinjamClientListener::onChatMessage, type, user, text); }); } @@ -674,10 +705,12 @@ bool NinjamClient::handleMessage(juce::uint8 type, void NinjamClient::sendAuthRequest(const juce::uint8 challenge[8]) { juce::uint8 hash[20]; - NinjamProtocol::computeAuthHash(currentUsername, currentPassword, challenge, + NinjamProtocol::computeAuthHash(currentUsername.toStdString(), + currentPassword.toStdString(), challenge, hash); - auto packet = NinjamProtocol::buildAuthUser(hash, currentUsername); - writeFull(0x80, packet.getData(), static_cast(packet.getSize())); + auto packet = + NinjamProtocol::buildAuthUser(hash, currentUsername.toStdString()); + writeFull(0x80, packet.data(), static_cast(packet.size())); } void NinjamClient::sendChannelInfo() { @@ -686,8 +719,13 @@ void NinjamClient::sendChannelInfo() { juce::ScopedLock sl(channelInfoMutex); names = storedChannelNames; } - auto payload = NinjamProtocol::buildChannelInfo(names); - writeFull(0x82, payload.getData(), static_cast(payload.getSize())); + std::vector nameList; + nameList.reserve(static_cast(names.size())); + for (const auto &n : names) + nameList.push_back(n.toStdString()); + + auto payload = NinjamProtocol::buildChannelInfo(nameList); + writeFull(0x82, payload.data(), static_cast(payload.size())); } void NinjamClient::updateChannelInfo(const juce::StringArray &names) { @@ -733,8 +771,7 @@ void NinjamClient::processCapturedAudio(juce::AudioBuffer &buffer, const char fourcc[4] = {'O', 'G', 'G', 'v'}; auto beginPacket = NinjamProtocol::buildIntervalBegin(guid, 0, fourcc, channelIndex); - writeFull(0x83, beginPacket.getData(), - static_cast(beginPacket.getSize())); + writeFull(0x83, beginPacket.data(), static_cast(beginPacket.size())); // The stream must declare the rate the audio is actually at, or every // listener resamples it -- a 44.1 kHz session sent as 48 kHz plays back @@ -772,8 +809,7 @@ void NinjamClient::processCapturedAudio(juce::AudioBuffer &buffer, auto writePacket = NinjamProtocol::buildIntervalWrite(guid, false, oggData, avail); - writeFull(0x84, writePacket.getData(), - static_cast(writePacket.getSize())); + writeFull(0x84, writePacket.data(), static_cast(writePacket.size())); sessionWriter.appendClip(guidHex, oggData, avail); @@ -797,8 +833,7 @@ void NinjamClient::processCapturedAudio(juce::AudioBuffer &buffer, auto writePacket = NinjamProtocol::buildIntervalWrite(guid, true, oggData, avail); - writeFull(0x84, writePacket.getData(), - static_cast(writePacket.getSize())); + writeFull(0x84, writePacket.data(), static_cast(writePacket.size())); sessionWriter.appendClip(guidHex, oggData, avail); @@ -1164,7 +1199,7 @@ void NinjamClient::setRemoteUserOutputBus(const juce::String &username, } void NinjamClient::sendUserMask() { - std::vector> masks; + std::vector> masks; { juce::ScopedLock sl(usersMutex); for (auto &[uname, user] : remoteUsers) { @@ -1172,14 +1207,14 @@ void NinjamClient::sendUserMask() { for (auto &[chIdx, ch] : user.channels) if (chIdx >= 0 && chIdx < 32 && ch.recvEnabled) mask |= (1u << chIdx); - masks.emplace_back(uname, mask); + masks.emplace_back(uname.toStdString(), mask); } } if (masks.empty()) return; auto payload = NinjamProtocol::buildUsermask(masks); - writeFull(0x81, payload.getData(), (int)payload.getSize()); + writeFull(0x81, payload.data(), (int)payload.size()); } void NinjamClient::mixSlotRange(int first, int last, @@ -1713,21 +1748,22 @@ juce::Array NinjamClient::getChatLog() const { void NinjamClient::sendChatMessage(const juce::String &text) { if (!isConnected()) return; - auto msgBlock = NinjamProtocol::buildChat("MSG", text); - writeFull(0xC0, msgBlock.getData(), static_cast(msgBlock.getSize())); + auto msgBlock = NinjamProtocol::buildChat("MSG", text.toStdString()); + writeFull(0xC0, msgBlock.data(), static_cast(msgBlock.size())); } void NinjamClient::sendAdminCommand(const juce::String &command) { if (!isConnected()) return; - auto msgBlock = NinjamProtocol::buildChat("ADMIN", command); - writeFull(0xC0, msgBlock.getData(), static_cast(msgBlock.getSize())); + auto msgBlock = NinjamProtocol::buildChat("ADMIN", command.toStdString()); + writeFull(0xC0, msgBlock.data(), static_cast(msgBlock.size())); } void NinjamClient::sendPrivateMessage(const juce::String &username, const juce::String &text) { if (!isConnected()) return; - auto msgBlock = NinjamProtocol::buildChat("PRIVMSG", username, text); - writeFull(0xC0, msgBlock.getData(), static_cast(msgBlock.getSize())); + auto msgBlock = NinjamProtocol::buildChat("PRIVMSG", username.toStdString(), + text.toStdString()); + writeFull(0xC0, msgBlock.data(), static_cast(msgBlock.size())); } diff --git a/src/NinjamClient.h b/src/NinjamClient.h index 5b56c14..af53b79 100644 --- a/src/NinjamClient.h +++ b/src/NinjamClient.h @@ -4,6 +4,7 @@ #include "SessionWriter.h" #include "SpscRing.h" #include "VorbisCodec.h" +#include #include #include #include @@ -17,6 +18,21 @@ class NinjamClientListener { virtual void onDisconnected(const juce::String &) {} virtual void onServerConfig(int, int) {} virtual void onUserInfoChange() {} + + // Somebody joined or left, carrying WHO rather than only that something + // changed. + // + // This exists because `getRoomMembers()` cannot answer the question. The set + // is maintained on the network thread the instant a JOIN or PART arrives, + // while listener callbacks are dispatched to the message thread afterwards -- + // so a listener asking "is this person here now?" is asking about a list that + // may already have moved on. A player who joins and leaves inside one + // message-thread gap is, from the listener's side, someone who was never + // there at all. + // + // The event carries the fact instead, and a fact does not go stale. + virtual void onRoomMembershipChange(const juce::String & /*username*/, + bool /*joined*/) {} virtual void onChatMessage(const juce::String &type, const juce::String &username, const juce::String &text) {} @@ -83,6 +99,16 @@ class NinjamClient : public juce::Thread { int channelIndex, bool mono); void getDecodedAudio(juce::AudioBuffer &buffer); + // Whether a channel is subscribed to the moment it is first seen. Set before + // connecting. + // + // A client that only transmits wants none of it: an unsubscribed channel + // never causes the server to send an interval, so it never causes one to be + // allocated here. That is what keeps a practice room of bots costing one + // client's worth of interval buffers rather than one per bot. Turning recv + // off after the fact would leave a window in which audio arrives anyway. + void setDefaultRecvEnabled(bool enabled) { defaultRecvEnabled = enabled; } + void setSampleRate(double sr) { sampleRate = sr; } void setServerBpm(int bpm) { serverBpm = bpm; } void setServerBpi(int bpi) { serverBpi = bpi; } @@ -435,6 +461,8 @@ class NinjamClient : public juce::Thread { int serverBpm = 120; int serverBpi = 16; + std::atomic defaultRecvEnabled{true}; + juce::String currentHost; int currentPort = 2049; juce::String currentUsername; @@ -444,7 +472,8 @@ class NinjamClient : public juce::Thread { void updateChannelParam(const juce::String &username, int channelIndex, ApplyToChannel toChannel, ApplyToSlot toSlot); - bool handleMessage(juce::uint8 type, const juce::MemoryBlock &payload); + bool handleMessage(juce::uint8 type, + const chalkwalk::ninjam::ByteBuffer &payload); void sendAuthRequest(const juce::uint8 challenge[8]); void sendChannelInfo(); void sendUserMask(); diff --git a/src/NinjamProtocol.cpp b/src/NinjamProtocol.cpp deleted file mode 100644 index c684652..0000000 --- a/src/NinjamProtocol.cpp +++ /dev/null @@ -1,359 +0,0 @@ -#include "NinjamProtocol.h" - -#include "Sha1.h" - -#include - -namespace NinjamProtocol { - -// --------------------------------------------------------------------------- -// Framing -// --------------------------------------------------------------------------- - -void writeFrameHeader(juce::uint8 out[kHeaderSize], juce::uint8 type, - juce::uint32 length) { - out[0] = type; - const juce::uint32 le = juce::ByteOrder::swapIfBigEndian(length); - memcpy(out + 1, &le, 4); -} - -bool readFrameHeader(const void *fiveBytes, FrameHeader &out) { - const auto *b = static_cast(fiveBytes); - juce::uint32 le; - memcpy(&le, b + 1, 4); - const juce::uint32 len = juce::ByteOrder::swapIfBigEndian(le); - if (len > kMaxPayload) - return false; - out.type = b[0]; - out.length = len; - return true; -} - -// --------------------------------------------------------------------------- -// Reader -// --------------------------------------------------------------------------- - -Reader::Reader(const void *data, size_t size) noexcept - : p(static_cast(data)) { - if (p == nullptr) - size = 0; - end = p + size; -} - -bool Reader::need(size_t n) noexcept { - if (failed || remaining() < n) { - failed = true; - return false; - } - return true; -} - -bool Reader::u8(juce::uint8 &out) noexcept { - if (!need(1)) - return false; - out = *p++; - return true; -} - -bool Reader::i8(juce::int8 &out) noexcept { - juce::uint8 v; - if (!u8(v)) - return false; - out = static_cast(v); - return true; -} - -bool Reader::u16le(juce::uint16 &out) noexcept { - if (!need(2)) - return false; - out = (juce::uint16)((juce::uint16)p[0] | ((juce::uint16)p[1] << 8)); - p += 2; - return true; -} - -bool Reader::i16le(juce::int16 &out) noexcept { - juce::uint16 v; - if (!u16le(v)) - return false; - // Explicit two's-complement conversion: casting an out-of-range unsigned to - // a signed type is implementation-defined before C++20. - out = (v & 0x8000u) ? (juce::int16)((int)v - 65536) : (juce::int16)v; - return true; -} - -bool Reader::u32le(juce::uint32 &out) noexcept { - if (!need(4)) - return false; - out = (juce::uint32)p[0] | ((juce::uint32)p[1] << 8) | - ((juce::uint32)p[2] << 16) | ((juce::uint32)p[3] << 24); - p += 4; - return true; -} - -bool Reader::bytes(void *dest, size_t n) noexcept { - if (!need(n)) - return false; - memcpy(dest, p, n); - p += n; - return true; -} - -bool Reader::skip(size_t n) noexcept { - if (!need(n)) - return false; - p += n; - return true; -} - -bool Reader::cstr(juce::String &out) noexcept { - if (failed) - return false; - const juce::uint8 *nul = p; - while (nul < end && *nul != 0) - ++nul; - if (nul >= end) { - // No terminator before the end of the payload. - failed = true; - return false; - } - out = - juce::String::fromUTF8(reinterpret_cast(p), (int)(nul - p)); - p = nul + 1; - return true; -} - -// --------------------------------------------------------------------------- -// Parsers -// --------------------------------------------------------------------------- - -juce::String guidToHex(const juce::uint8 guid[16]) { - juce::String s; - s.preallocateBytes(33); - for (int i = 0; i < 16; ++i) - s += juce::String::toHexString((int)guid[i]).paddedLeft('0', 2); - return s; -} - -bool IntervalBegin::isOggAudio() const { - return fourcc[0] == 'O' && fourcc[1] == 'G' && fourcc[2] == 'G' && - fourcc[3] == 'v'; -} - -bool parseAuthChallenge(const juce::MemoryBlock &payload, AuthChallenge &out) { - Reader r(payload.getData(), payload.getSize()); - return r.bytes(out.challenge, 8); -} - -bool parseAuthReply(const juce::MemoryBlock &payload, AuthReply &out) { - out = AuthReply{}; - Reader r(payload.getData(), payload.getSize()); - juce::uint8 flag; - if (!r.u8(flag)) - return false; - out.granted = (flag == 1); - - // The message and channel cap are optional trailing fields; older servers - // send the flag alone (mpb.cpp mpb_server_auth_reply::parse). - if (r.atEnd()) - return true; - if (!r.cstr(out.errorMessage)) - return true; // tolerate a truncated tail rather than dropping the reply - - juce::uint8 maxchan; - if (r.u8(maxchan)) - out.maxChannels = maxchan; - return true; -} - -juce::MemoryBlock buildAuthReply(bool granted, const juce::String &errorMessage, - int maxChannels) { - juce::MemoryBlock b; - const juce::uint8 flag = granted ? 1 : 0; - b.append(&flag, 1); - b.append(errorMessage.toRawUTF8(), - (size_t)errorMessage.getNumBytesAsUTF8() + 1); - const juce::uint8 mc = (juce::uint8)juce::jlimit(0, 255, maxChannels); - b.append(&mc, 1); - return b; -} - -bool parseServerConfig(const juce::MemoryBlock &payload, ServerConfig &out) { - Reader r(payload.getData(), payload.getSize()); - juce::uint16 bpm, bpi; - if (!r.u16le(bpm) || !r.u16le(bpi)) - return false; - out.bpm = bpm; - out.bpi = bpi; - return true; -} - -bool parseUserInfo(const juce::MemoryBlock &payload, - std::vector &out) { - Reader r(payload.getData(), payload.getSize()); - while (!r.atEnd()) { - UserInfoEntry e; - juce::uint8 active, chIdx; - juce::int16 volume; - juce::int8 pan; - // The fixed part of a record is six bytes, not four. - if (!r.u8(active) || !r.u8(chIdx) || !r.i16le(volume) || !r.i8(pan) || - !r.u8(e.flags)) - return false; - if (!r.cstr(e.username) || !r.cstr(e.channelName)) - return false; - - e.active = (active != 0); - e.channelIndex = chIdx; - e.volume = volume; - e.pan = pan; - out.push_back(std::move(e)); - } - return true; -} - -bool parseIntervalBegin(const juce::MemoryBlock &payload, IntervalBegin &out) { - out = IntervalBegin{}; // never leave stale fields when reusing the struct - Reader r(payload.getData(), payload.getSize()); - if (!r.bytes(out.guid, 16) || !r.u32le(out.estimatedSize) || - !r.bytes(out.fourcc, 4)) - return false; - - juce::uint8 chIdx; - if (!r.u8(chIdx)) - return false; - out.channelIndex = chIdx; - out.guidHex = guidToHex(out.guid); - - // The 0x83 upload form stops here; the 0x04 download form adds a username. - if (!r.atEnd() && !r.cstr(out.username)) - return false; - return true; -} - -bool parseIntervalWrite(const juce::MemoryBlock &payload, IntervalWrite &out) { - out = IntervalWrite{}; - Reader r(payload.getData(), payload.getSize()); - juce::uint8 flags; - if (!r.bytes(out.guid, 16) || !r.u8(flags)) - return false; - out.guidHex = guidToHex(out.guid); - out.isFinal = (flags & 1) != 0; - out.audioSize = (int)r.remaining(); - out.audioData = out.audioSize > 0 ? r.rest() : nullptr; - return true; -} - -bool parseChat(const juce::MemoryBlock &payload, Chat &out) { - out = Chat{}; // trailing fields are optional, so they must start empty - Reader r(payload.getData(), payload.getSize()); - if (!r.cstr(out.type)) - return false; - - // Trailing fields are optional: a sender may simply stop early. Only a - // present-but-unterminated field is an error. - juce::String *fields[4] = {&out.p1, &out.p2, &out.p3, &out.p4}; - for (auto *f : fields) { - if (r.atEnd()) - break; - if (!r.cstr(*f)) - return false; - } - return true; -} - -// --------------------------------------------------------------------------- -// Builders -// --------------------------------------------------------------------------- - -void computeAuthHash(const juce::String &username, const juce::String &password, - const juce::uint8 challenge[8], juce::uint8 out[20]) { - Sha1 inner; - inner.add(username.toRawUTF8(), username.getNumBytesAsUTF8()); - inner.add(":", 1); - inner.add(password.toRawUTF8(), password.getNumBytesAsUTF8()); - juce::uint8 innerDigest[20]; - inner.result(innerDigest); - - Sha1 outer; - outer.add(innerDigest, 20); - outer.add(challenge, 8); - outer.result(out); -} - -juce::MemoryBlock buildAuthUser(const juce::uint8 hash[20], - const juce::String &username, juce::uint32 caps, - juce::uint32 version) { - juce::MemoryBlock b; - b.append(hash, 20); - b.append(username.toRawUTF8(), (size_t)username.getNumBytesAsUTF8() + 1); - const juce::uint32 leCaps = juce::ByteOrder::swapIfBigEndian(caps); - b.append(&leCaps, 4); - const juce::uint32 leVer = juce::ByteOrder::swapIfBigEndian(version); - b.append(&leVer, 4); - return b; -} - -juce::MemoryBlock -buildUsermask(const std::vector> &masks) { - juce::MemoryBlock b; - for (const auto &[name, mask] : masks) { - b.append(name.toRawUTF8(), (size_t)name.getNumBytesAsUTF8() + 1); - const juce::uint32 le = juce::ByteOrder::swapIfBigEndian(mask); - b.append(&le, 4); - } - return b; -} - -juce::MemoryBlock buildChannelInfo(const juce::StringArray &names) { - juce::MemoryBlock b; - // 2-byte LE mpisize: 4 bytes of per-channel metadata follow each name. The - // server reads exactly this many bytes after every name, so a wrong value - // desynchronises its parser for all subsequent channels. - const juce::uint8 mpisize[2] = {4, 0}; - b.append(mpisize, 2); - for (const auto &name : names) { - b.append(name.toRawUTF8(), (size_t)name.getNumBytesAsUTF8() + 1); - const juce::uint8 meta[4] = {0, 0, 0, 0}; // volume LE (0 dB), pan, flags - b.append(meta, 4); - } - return b; -} - -juce::MemoryBlock buildIntervalBegin(const juce::uint8 guid[16], - juce::uint32 estimatedSize, - const char fourcc[4], int channelIndex, - const juce::String &username) { - juce::MemoryBlock b; - b.append(guid, 16); - const juce::uint32 leSize = juce::ByteOrder::swapIfBigEndian(estimatedSize); - b.append(&leSize, 4); - b.append(fourcc, 4); - const juce::uint8 chIdx = (juce::uint8)channelIndex; - b.append(&chIdx, 1); - if (username.isNotEmpty()) - b.append(username.toRawUTF8(), (size_t)username.getNumBytesAsUTF8() + 1); - return b; -} - -juce::MemoryBlock buildIntervalWrite(const juce::uint8 guid[16], bool isFinal, - const void *audio, int audioSize) { - juce::MemoryBlock b; - b.append(guid, 16); - const juce::uint8 flags = isFinal ? 1 : 0; - b.append(&flags, 1); - if (audio != nullptr && audioSize > 0) - b.append(audio, (size_t)audioSize); - return b; -} - -juce::MemoryBlock buildChat(const juce::String &type, const juce::String &p1, - const juce::String &p2, const juce::String &p3, - const juce::String &p4) { - juce::MemoryBlock b; - const juce::String *fields[5] = {&type, &p1, &p2, &p3, &p4}; - for (auto *f : fields) - b.append(f->toRawUTF8(), (size_t)f->getNumBytesAsUTF8() + 1); - return b; -} - -} // namespace NinjamProtocol diff --git a/src/NinjamProtocol.h b/src/NinjamProtocol.h index 10052e1..7d7e9c2 100644 --- a/src/NinjamProtocol.h +++ b/src/NinjamProtocol.h @@ -1,207 +1,22 @@ #pragma once -#include -#include -#include -// Byte-level Ninjam protocol: framing, message parsing, message building. +// Adopted from chalkwalk-ninjam (libs/ninjam, MIT). // -// Everything here is pure -- no sockets, no threads, no shared state -- so it -// can be exercised directly by tests, including with deliberately malformed -// input. NinjamClient keeps the stateful dispatch; only the wire format lives -// here. +// Unlike the other five files that moved out to that library, this one changed +// shape on the way: a JUCE-free library cannot have juce::MemoryBlock and +// juce::String in its signatures, so payloads are now ByteBuffer +// (std::vector) and every string is std::string. // -// All multi-byte integers in the Ninjam protocol are LITTLE-ENDIAN -// (justinfrankel/ninjam mpb.cpp:192-195 for bpm/bpi, :281-282 for volume). +// The namespace alias keeps `NinjamProtocol::` spelled as it always was, which +// is most of the call sites. The conversions that remain are real and are +// written out at each site rather than hidden behind a wrapper: juce::String +// constructs from std::string implicitly, so parsed fields flow into the UI +// untouched, and the other direction costs an explicit .toStdString() that +// says plainly where the boundary is. -namespace NinjamProtocol { +#include +#include -enum Msg : juce::uint8 { - ServerAuthChallenge = 0x00, - ServerAuthReply = 0x01, - ServerConfigChange = 0x02, - ServerUserInfoChange = 0x03, - DownloadIntervalBegin = 0x04, - DownloadIntervalWrite = 0x05, - ClientAuthUser = 0x80, - ClientSetUsermask = 0x81, - ClientSetChannelInfo = 0x82, - UploadIntervalBegin = 0x83, - UploadIntervalWrite = 0x84, - ChatMessage = 0xC0, - KeepAlive = 0xFD -}; +namespace NinjamProtocol = chalkwalk::ninjam::protocol; -// --------------------------------------------------------------------------- -// Framing: 1-byte type + 4-byte little-endian payload length. -// --------------------------------------------------------------------------- - -static constexpr int kHeaderSize = 5; -static constexpr juce::uint32 kMaxPayload = 10u * 1024 * 1024; - -struct FrameHeader { - juce::uint8 type = 0; - juce::uint32 length = 0; -}; - -void writeFrameHeader(juce::uint8 out[kHeaderSize], juce::uint8 type, - juce::uint32 length); - -// Rejects lengths above kMaxPayload. -bool readFrameHeader(const void *fiveBytes, FrameHeader &out); - -// --------------------------------------------------------------------------- -// Bounds-checked cursor. Every accessor returns false and leaves the output -// untouched if the read would run past the end; once a read fails the cursor -// latches failed so callers may check ok() once at the end instead of after -// every field. -// --------------------------------------------------------------------------- - -class Reader { -public: - Reader(const void *data, size_t size) noexcept; - - bool u8(juce::uint8 &out) noexcept; - bool i8(juce::int8 &out) noexcept; - bool u16le(juce::uint16 &out) noexcept; - bool i16le(juce::int16 &out) noexcept; - bool u32le(juce::uint32 &out) noexcept; - bool bytes(void *dest, size_t n) noexcept; - bool skip(size_t n) noexcept; - - // Reads up to the next NUL. Fails, without advancing, if no NUL appears - // before the end of the payload. This is what keeps a truncated or hostile - // record from walking off the end of the buffer. - bool cstr(juce::String &out) noexcept; - - const void *rest() const noexcept { return p; } - size_t remaining() const noexcept { return (size_t)(end - p); } - bool ok() const noexcept { return !failed; } - bool atEnd() const noexcept { return p >= end; } - -private: - bool need(size_t n) noexcept; - - const juce::uint8 *p; - const juce::uint8 *end; - bool failed = false; -}; - -// --------------------------------------------------------------------------- -// Parsed message forms. -// --------------------------------------------------------------------------- - -struct AuthChallenge { - juce::uint8 challenge[8] = {}; -}; - -struct AuthReply { - bool granted = false; - juce::String errorMessage; - // Maximum local channel index the server will accept. The reference client - // refuses to transmit on any channel at or above this - // (justinfrankel/ninjam njclient.cpp:1096, :1476), so a server that - // omits it gets no audio at all from a stock client. Absent on older - // servers, in which case it stays 0. - int maxChannels = 0; -}; - -struct ServerConfig { - int bpm = 0; - int bpi = 0; -}; - -struct UserInfoEntry { - bool active = false; - int channelIndex = 0; - int volume = 0; - int pan = 0; - juce::uint8 flags = 0; - juce::String username; - juce::String channelName; -}; - -struct IntervalBegin { - juce::uint8 guid[16] = {}; - juce::String guidHex; - juce::uint32 estimatedSize = 0; - char fourcc[4] = {}; - int channelIndex = 0; - juce::String username; // empty for the 0x83 upload form - bool isOggAudio() const; -}; - -struct IntervalWrite { - juce::uint8 guid[16] = {}; - juce::String guidHex; - bool isFinal = false; - const void *audioData = nullptr; // view into the caller's payload - int audioSize = 0; -}; - -struct Chat { - juce::String type, p1, p2, p3, p4; -}; - -// Each returns false on malformed input, having read nothing past the payload. -bool parseAuthChallenge(const juce::MemoryBlock &payload, AuthChallenge &out); -bool parseAuthReply(const juce::MemoryBlock &payload, AuthReply &out); -bool parseServerConfig(const juce::MemoryBlock &payload, ServerConfig &out); - -// Returns false if any record is malformed. Records successfully parsed before -// the failure are retained in `out`, matching the reference server's forgiving -// treatment of trailing garbage. -bool parseUserInfo(const juce::MemoryBlock &payload, - std::vector &out); - -// Handles both DOWNLOAD_INTERVAL_BEGIN (0x04, with username) and -// UPLOAD_INTERVAL_BEGIN (0x83, exactly 25 bytes, no username). -bool parseIntervalBegin(const juce::MemoryBlock &payload, IntervalBegin &out); - -// Handles both 0x05 and 0x84 -- the payload layouts are identical. -bool parseIntervalWrite(const juce::MemoryBlock &payload, IntervalWrite &out); - -bool parseChat(const juce::MemoryBlock &payload, Chat &out); - -// --------------------------------------------------------------------------- -// Builders. -// --------------------------------------------------------------------------- - -// Ninjam challenge-response: SHA1(SHA1(user + ":" + pass) + challenge[0..8]). -void computeAuthHash(const juce::String &username, const juce::String &password, - const juce::uint8 challenge[8], juce::uint8 out[20]); - -// Server side, used by the test fixtures. A real server always sends the -// channel cap; omitting it stops a stock client transmitting entirely. -juce::MemoryBlock buildAuthReply(bool granted, - const juce::String &errorMessage = {}, - int maxChannels = 32); - -juce::MemoryBlock buildAuthUser(const juce::uint8 hash[20], - const juce::String &username, - juce::uint32 caps = 1, - juce::uint32 version = 0x00020000); - -// Channel indices >= 32 are dropped rather than shifted (1u << 32 is UB). -juce::MemoryBlock -buildUsermask(const std::vector> &masks); - -juce::MemoryBlock buildChannelInfo(const juce::StringArray &names); - -// Pass an empty username for the 0x83 upload form (exactly 25 bytes). -juce::MemoryBlock buildIntervalBegin(const juce::uint8 guid[16], - juce::uint32 estimatedSize, - const char fourcc[4], int channelIndex, - const juce::String &username = {}); - -juce::MemoryBlock buildIntervalWrite(const juce::uint8 guid[16], bool isFinal, - const void *audio, int audioSize); - -juce::MemoryBlock buildChat(const juce::String &type, - const juce::String &p1 = {}, - const juce::String &p2 = {}, - const juce::String &p3 = {}, - const juce::String &p4 = {}); - -juce::String guidToHex(const juce::uint8 guid[16]); - -} // namespace NinjamProtocol +using chalkwalk::ninjam::ByteBuffer; diff --git a/src/PluginEditor.cpp b/src/PluginEditor.cpp index 2a39661..485c9fa 100644 --- a/src/PluginEditor.cpp +++ b/src/PluginEditor.cpp @@ -1,5 +1,7 @@ #include "GainUtils.h" #include "PluginEditor.h" + +#include "RoomHarmony.h" #include "PluginProcessor.h" #include "AccessibilityTree.h" #include "ServerBrowserDialog.h" @@ -324,6 +326,12 @@ AntiphonEditor::AntiphonEditor(AntiphonAudioProcessor &p) dismissedVoteTarget = pendingVote.target; dismissedVoteIsBpm = pendingVote.isBpm; pendingVote = {}; + } else if (keyFromChords.confident) { + // Exactly what /key sends, so a suggestion accepted and a key typed are + // the same message to everyone else in the room. + audioProcessor.ninjamClient.sendChatMessage( + MusicalKey::buildTagged(keyFromChords.key)); + dismissedKeyGuess = keyFromChords.key; } updateTempoChip(); }; @@ -339,6 +347,8 @@ AntiphonEditor::AntiphonEditor(AntiphonAudioProcessor &p) dismissedVoteTarget = pendingVote.target; dismissedVoteIsBpm = pendingVote.isBpm; pendingVote = {}; + } else if (keyFromChords.confident) { + dismissedKeyGuess = keyFromChords.key; } updateTempoChip(); }; @@ -357,9 +367,9 @@ AntiphonEditor::AntiphonEditor(AntiphonAudioProcessor &p) chatInput.setName("chatInput"); chatInput.setMultiLine(false); chatInput.setReturnKeyStartsNewLine(false); - chatInput.setTextToShowWhenEmpty( - "Message, or a command: /key Dm, /bpm 120, /bpi 16, /msg user text", - juce::Colours::grey); + chatInput.setTextToShowWhenEmpty("Message, or a command: /key Dm, /chords Am " + "F C G, /bpm 120, /msg user text", + juce::Colours::grey); chatInput.onReturnKey = [this]() { juce::String text = chatInput.getText().trim(); if (text.isNotEmpty()) { @@ -373,7 +383,7 @@ AntiphonEditor::AntiphonEditor(AntiphonAudioProcessor &p) // and which we parse back on the way in. Nothing is set locally here -- // the message we receive is what updates the header, so what we display // is exactly what the room was told. - const auto key = MusicalKey::parseName(text.substring(5)); + const auto key = MusicalKey::parseName(text.substring(5).toStdString()); if (key.valid) { audioProcessor.ninjamClient.sendChatMessage( MusicalKey::buildTagged(key)); @@ -381,6 +391,30 @@ AntiphonEditor::AntiphonEditor(AntiphonAudioProcessor &p) chatDisplay.insertTextAtCaret("Local: not a key. Try /key Dm, /key " "F# Dorian, /key Bb major.\n"); } + } else if (text.startsWithIgnoreCase("/chords ")) { + // Degrees are resolved here and only here. What goes on the wire is + // the absolute chart, so every bot, and every client that is not + // Antiphon, sees chords it already understands. + juce::String chart = text.substring(8).trim(); + if (!chart.startsWithChar('|')) + chart = "| " + chart.replace(" ", " | ") + " |"; + + Harmony::Chart parsed; + if (Harmony::parseChart(chart.toStdString(), parsed)) { + audioProcessor.ninjamClient.sendChatMessage( + Harmony::chartText(parsed, sessionKey)); + } else if (!sessionKey.valid) { + chatDisplay.insertTextAtCaret( + "Local: set a key first, and then degrees will work: /key Dm.\n"); + } else if (Harmony::parseDegreeChart(chart.toStdString(), sessionKey, + parsed)) { + audioProcessor.ninjamClient.sendChatMessage( + Harmony::chartText(parsed, sessionKey)); + } else { + chatDisplay.insertTextAtCaret( + "Local: not chords. Try /chords Am F C G, or in degrees, " + "/chords ii V I.\n"); + } } else if (text.startsWithIgnoreCase("/topic ") || text.startsWithIgnoreCase("/kick ") || text.startsWithIgnoreCase("/bpm ") || @@ -405,8 +439,8 @@ AntiphonEditor::AntiphonEditor(AntiphonAudioProcessor &p) } } else if (text.startsWithChar('/')) { chatDisplay.insertTextAtCaret( - "Local: unknown command. Try /key, /topic, /kick, /bpm, /bpi, " - "/msg, " + "Local: unknown command. Try /key, /chords, /topic, /kick, /bpm, " + "/bpi, /msg, " "/me, or /admin to pass a command straight to the " "server.\n"); } else { @@ -565,15 +599,54 @@ void AntiphonEditor::onChatMessage(const juce::String &type, chatDisplay.moveCaretToEnd(); chatDisplay.insertTextAtCaret(line.text + "\n"); - // A key can arrive as chat or inside a topic; both land here. - if (const auto key = MusicalKey::parseTagged(text); key.valid) { - if (key != sessionKey) { - sessionKey = key; - announcer.say("Key: " + MusicalKey::displayName(key) + ". " + - MusicalKey::scaleNotes(key), + // A key or a chart can arrive as chat or inside a topic, tagged, as `/key`, + // in letters or in degrees -- and what each does to the other is decided in + // `RoomHarmony`, the one place the band consults too. + // + // It used to be decided here as well, and the two drifted: the band learned + // to read `| ii | V | I |` and to carry a chart through a key change, and + // this did neither. The chord row and the marks on the phase bar went on + // showing the chart before, with nothing to say they were stale + // (`PRINCIPLES` 8). + RoomHarmony::State room; + room.key = sessionKey; + room.chart = sessionChart; + room.chartFromChat = chartFromChat; + + switch (RoomHarmony::apply(text.toStdString(), room)) { + case RoomHarmony::Change::Key: { + sessionKey = room.key; + sessionChart = room.chart; + announcer.say("Key: " + MusicalKey::displayName(sessionKey) + ". " + + MusicalKey::scaleNotes(sessionKey), + true); + // The chart moved with it, so say so rather than leaving a reader to + // wonder whether the chords they were told still apply. + if (!sessionChart.empty()) + announcer.say("Chords: " + Harmony::chartText(sessionChart, sessionKey), true); - repaint(headerRepaintArea); - } + updateTempoChip(); + resized(); + repaint(); + break; + } + case RoomHarmony::Change::Chart: { + sessionChart = room.chart; + chartFromChat = true; + announcer.say("Chords: " + Harmony::chartText(sessionChart, sessionKey), + true); + + // Chords are evidence about the key, so this is where the guess is made. + // It is offered on the chip and never acted on: a suggestion that set the + // key by itself would be a client deciding something the room did not. + keyFromChords = Harmony::inferKey(Harmony::flatten(sessionChart)); + updateTempoChip(); + resized(); // the header grows the first time a chart appears + repaint(headerRepaintArea); + break; + } + case RoomHarmony::Change::None: + break; } // The voting system talks through chat, so this is also where a vote is @@ -595,7 +668,7 @@ void AntiphonEditor::paint(juce::Graphics &g) { getLookAndFeel().findColour(juce::ResizableWindow::backgroundColourId)); auto area = getLocalBounds().reduced(10); - auto header = area.removeFromTop(80); + auto header = area.removeFromTop(headerHeight()); const bool connected = audioProcessor.ninjamClient.isConnected(); const bool connectFailed = audioProcessor.lastConnectFailed.load(); @@ -627,6 +700,21 @@ void AntiphonEditor::paint(juce::Graphics &g) { auto row2 = header.removeFromTop(18); g.setFont(juce::FontOptions{}.withHeight(13.0f)); + + // The chart in roman numerals, at the far end of the row the key is on -- + // laid out from both ends, as the toolbar is. The absolute names go on the + // timeline below, where their position carries the timing; here it is the + // shape of the progression, which is what a numeral is for. + if (connected && showsChartRow() && sessionKey.valid) { + const juce::String roman = + Harmony::romanChartText(sessionChart, sessionKey); + if (roman.isNotEmpty()) { + g.setColour(juce::Colours::white.withAlpha(0.55f)); + g.drawFittedText(roman, row2.removeFromRight(320), + juce::Justification::centredRight, 1); + } + } + if (connected) { g.setColour(juce::Colours::white); // The running tempo, not the pending one. A server change that has not @@ -642,7 +730,8 @@ void AntiphonEditor::paint(juce::Graphics &g) { tempoText += " (-> " + juce::String(wantBpm) + " / " + juce::String(wantBpi) + " next interval)"; if (sessionKey.valid) - tempoText += " Key " + MusicalKey::displayName(sessionKey); + tempoText += + " Key " + juce::String(MusicalKey::displayName(sessionKey)); g.drawFittedText(tempoText, row2, juce::Justification::centredLeft, 1); } else { g.setColour(juce::Colours::darkgrey); @@ -652,6 +741,62 @@ void AntiphonEditor::paint(juce::Graphics &g) { header.removeFromTop(4); + // The chart, laid along the same axis as the phase bar below it, so each + // chord name sits at the point in the interval where it actually starts and + // the teal fill sweeps through them. Position is the information here: it is + // what lets you see the next change coming rather than read that it exists. + if (showsChartRow()) { + auto chartRow = header.removeFromTop(14); + const int bpi = audioProcessor.publishedActiveBpi.load(); + const auto layout = Harmony::layoutChart(sessionChart, bpi); + if (!layout.empty()) { + const float phase = audioProcessor.publishedPhaseBeats.load(); + const int nowStep = juce::jlimit(0, layout.steps() - 1, + (int)(phase * Harmony::kStepsPerBeat)); + const int nowChord = layout.stepToChord[(size_t)nowStep]; + + g.setFont(juce::FontOptions{}.withHeight(12.0f)); + int previousRight = chartRow.getX(); + for (int step = 0; step < layout.steps(); ++step) { + if (!Harmony::changesAtStep(layout, step)) + continue; + + const int idx = layout.stepToChord[(size_t)step]; + const int x = + chartRow.getX() + (int)((float)step / (float)layout.steps() * + (float)chartRow.getWidth()); + + // Where the next change is, so a label never runs into its neighbour. + int nextStep = layout.steps(); + for (int s = step + 1; s < layout.steps(); ++s) + if (Harmony::changesAtStep(layout, s)) { + nextStep = s; + break; + } + const int room = + (int)((float)(nextStep - step) / (float)layout.steps() * + (float)chartRow.getWidth()); + + const bool isNow = idx == nowChord; + // A label that will not fit is dropped rather than overlapped -- except + // the one sounding now, which is the one you are actually reading. + if (x < previousRight && !isNow) + continue; + + g.setColour(isNow ? teal : juce::Colours::white.withAlpha(0.45f)); + const auto name = + Harmony::chordName(layout.chords[(size_t)idx], sessionKey); + g.drawFittedText(name, + juce::Rectangle(x, chartRow.getY(), + juce::jmax(24, room - 4), + chartRow.getHeight()), + juce::Justification::centredLeft, 1); + previousRight = x + juce::jmin(room, 6 + (int)name.length() * 7); + } + } + header.removeFromTop(2); + } + auto phaseBar = header.removeFromTop(8); g.setColour(juce::Colour(0xff1a1a2e)); g.fillRect(phaseBar); @@ -772,7 +917,7 @@ void AntiphonEditor::resized() { auto area = getLocalBounds().reduced(10); // Covers the painted header exactly, so the spoken status and the drawn one // describe the same region of the window. - const auto header = area.removeFromTop(80); + const auto header = area.removeFromTop(headerHeight()); statusReadout.setBounds(header); // What the 30 Hz tick repaints: the header band plus the section-label row // just below it, both of which paint() draws. @@ -1381,6 +1526,20 @@ void AntiphonEditor::updateRoomMembers() { } } +bool AntiphonEditor::showsChartRow() const { + // Only ever a chart somebody announced. In a jam, drawing a progression the + // room did not agree to would be a lie -- the other players are not playing + // it -- and the practice room's band is the one case where a default chart + // is the truth, which is why that case waits for the room to be wired in. + return audioProcessor.ninjamClient.isConnected() && !sessionChart.empty() && + audioProcessor.publishedActiveBpi.load() > 0; +} + +int AntiphonEditor::headerHeight() const { + // 14 for the labels and 2 to separate them from the bar they belong to. + return showsChartRow() ? 96 : 80; +} + void AntiphonEditor::setChipVisible(bool shouldShow) { if (chipLabel.isVisible() == shouldShow) return; @@ -1416,13 +1575,34 @@ void AntiphonEditor::updateTempoChip() { return; } + // Then a key the chords imply but nobody has declared. Below a live vote, + // because a vote is a decision in progress and this is only an observation; + // above the DAW tempo, because it is about the music rather than the setup. + if (keyFromChords.confident && keyFromChords.key != sessionKey && + keyFromChords.key != dismissedKeyGuess) { + chipDawBpm = 0; + const juce::String t = + "These chords look like " + MusicalKey::displayName(keyFromChords.key); + chipLabel.setText(t, juce::dontSendNotification); + chipLabel.setTitle(t); + chipActionButton.setButtonText("Set key"); + chipActionButton.setDescription("Tell the room the key is " + + MusicalKey::displayName(keyFromChords.key)); + setChipVisible(true); + return; + } + // Otherwise: the DAW is at a different tempo from the server. This only // offers the vote -- changing your DAW tempo never casts one. const int serverBpm = audioProcessor.publishedActiveBpm.load(); const int hostBpm = (int)std::lround(audioProcessor.hostBpm); + // A tempo the server would refuse is not worth offering: an out-of-range + // `!vote` is answered with a complaint about the command's parameters, which + // tells a player nothing about the real problem. A DAW at 30 BPM is ordinary. const bool worthProposing = !audioProcessor.isStandaloneApp() && hostBpm > 0 && serverBpm > 0 && - hostBpm != serverBpm && hostBpm != dismissedDawBpm; + hostBpm != serverBpm && hostBpm != dismissedDawBpm && + ChatFormat::isVotableBpm(hostBpm); if (worthProposing) { chipDawBpm = hostBpm; const juce::String t = "Your DAW is at " + juce::String(hostBpm) + " BPM"; @@ -1469,9 +1649,9 @@ void AntiphonEditor::setChatConnectedState(bool connected) { chatInput.setColour(juce::TextEditor::outlineColourId, juce::Colour(AntiphonTheme::kDisabledEdge)); chatInput.setTextToShowWhenEmpty( - connected - ? "Message, or a command: /key Dm, /bpm 120, /bpi 16, /msg user text" - : "Not connected -- join a server to chat", + connected ? "Message, or a command: /key Dm, /chords Am F C G, /bpm 120, " + "/msg user text" + : "Not connected -- join a server to chat", juce::Colour(connected ? 0xff8a8a8a : AntiphonTheme::kDisabledText)); chatInput.repaint(); } @@ -1482,9 +1662,14 @@ void AntiphonEditor::onConnected() { } void AntiphonEditor::onDisconnected(const juce::String &) { - // The key belongs to the session, not to us. + // The key and the chords belong to the session, not to us. sessionKey = {}; + sessionChart.clear(); + chartFromChat = false; + keyFromChords = {}; + dismissedKeyGuess = {}; setChatConnectedState(false); + resized(); // the header gives the chart row back } void AntiphonEditor::updateToolbarStates() { @@ -1642,6 +1827,17 @@ bool AntiphonEditor::updateStatusReadout() { << (audioProcessor.lastUsername.isNotEmpty() ? audioProcessor.lastUsername : juce::String("anonymous")) << ". " << bpm << " BPM, " << bpi << " beats per interval. "; + + // The key and the chart belong in the spoken status because they are drawn + // in the header: a reader should get what a viewer gets. The chord SOUNDING + // is deliberately not here -- it changes several times a bar, and reading + // state that moves on a timer is exactly what PRINCIPLES 11 refuses. + if (sessionKey.valid) + s << "Key " << MusicalKey::displayName(sessionKey) << ". "; + if (showsChartRow()) { + s << "Chords " << Harmony::chartText(sessionChart, sessionKey) << ". "; + } + s << (audioProcessor.isStandaloneApp() ? juce::String("Running.") : juce::String(SyncState::describe(sync)) + "."); diff --git a/src/PluginEditor.h b/src/PluginEditor.h index 65b400d..2cf0d08 100644 --- a/src/PluginEditor.h +++ b/src/PluginEditor.h @@ -4,6 +4,7 @@ #include "NinjamClient.h" #include "AntiphonLookAndFeel.h" #include "ChatFormat.h" +#include "Harmony.h" #include "MusicalKey.h" #include "Announcer.h" #include "Shortcuts.h" @@ -179,6 +180,11 @@ class AntiphonEditor : public juce::AudioProcessorEditor, void updateTempoChip(); void setChipVisible(bool shouldShow); + // The chart row appears only when there is a chart, so an idle header keeps + // the height it has always had. + int headerHeight() const; + bool showsChartRow() const; + // The server vote currently on offer, and the DAW tempo currently worth // proposing. Dismissal is remembered per value, so saying no to one proposal // does not silence the next, different one. @@ -187,6 +193,22 @@ class AntiphonEditor : public juce::AudioProcessorEditor, // The key the room is playing in, as last announced by anyone. Display only: // Ninjam has no field for it, so it rides on chat (see MusicalKey.h). MusicalKey::Key sessionKey; + + // The chart the room is playing over, as last announced by anyone -- and + // only ever that. A progression nobody agreed to would be a lie on screen, + // so nothing is inferred or defaulted into this. + Harmony::Chart sessionChart; + + // Whether the chart is one somebody put up or one the key implied. What a + // key change turns on: preserve what was written, re-derive what was + // delegated (`DESIGN.md` section 6.4, `RoomHarmony`). + bool chartFromChat = false; + + // A key the chords imply but nobody has declared. Offered on the chip, never + // acted on by itself, and never sent anywhere: clicking is what announces it. + Harmony::KeyGuess keyFromChords; + MusicalKey::Key dismissedKeyGuess; + int dismissedVoteTarget = 0; bool dismissedVoteIsBpm = true; int dismissedDawBpm = 0; diff --git a/src/PracticeRoom.cpp b/src/PracticeRoom.cpp new file mode 100644 index 0000000..ad8a120 --- /dev/null +++ b/src/PracticeRoom.cpp @@ -0,0 +1,188 @@ +#include "PracticeRoom.h" + +#include "NinjamBotClient.h" + +#include + +#include "IntervalClock.h" + +PracticeRoom::PracticeRoom() = default; + +PracticeRoom::~PracticeRoom() { stop(); } + +bool PracticeRoom::start(const Config &config) { + stop(); + cfg = config; + + if (cfg.bpm <= 0 || cfg.bpi <= 0 || cfg.sampleRate <= 0.0) + return false; + + if (!server.start(cfg.bpm, cfg.bpi)) + return false; + server.setTopic(cfg.topic); + + // The same truncating arithmetic every other client on a server uses + // (justinfrankel/ninjam njclient.cpp:806). A bot that rounded differently + // would drift against the room by a sample per interval. + IntervalClock clock; + clock.prepare(cfg.sampleRate); + clock.setTempo(cfg.bpm, cfg.bpi); + intervalSamples = clock.samplesPerInterval(); + if (intervalSamples <= 0) { + server.stop(); + return false; + } + + { + juce::ScopedLock sl(botsMutex); + bots.clear(); + + const BotBand::Voice voices[] = {BotBand::Voice::Drums, + BotBand::Voice::Bass, BotBand::Voice::Keys, + BotBand::Voice::Lead}; + + // Names before players, because a name has to be checked against the room. + // + // The owner is already in it -- or about to be -- so their name is what a + // bot's handle must not collide with. See BotNames.h for why the handle + // matters enough to pick around: it is what lets "what are the changes + // delvo" work, and an ambiguous one costs the bot natural address for the + // whole session. + std::vector taken; + if (cfg.ownerName.isNotEmpty()) + taken.push_back(cfg.ownerName.toStdString()); + const auto chosen = BotNames::bandFor(4, cfg.seed, taken); + + std::uint32_t seed = cfg.seed; + int index = 0; + for (auto voice : voices) { + const juce::String instrument = + juce::String(BotBand::voiceName(voice)).toLowerCase(); + + // One token, no spaces, so `/msg` can reach it in every client. The + // marker is for the human reading the mixer -- a strip that is not a + // person should say so -- and for other bots deciding whether to answer. + // It identifies nothing and is spoofable, which is fine, because it + // decides only who talks. + const juce::String botUsername = BotNames::usernameFor( + chosen[(size_t)index++], instrument.toStdString()); + + // Antiphon's client, behind the interface the bots see. This is the one + // place the plugin's transport meets the band. + auto bot = std::make_unique( + botUsername.toStdString(), + std::vector{instrument.toStdString()}, + std::make_unique()); + bot->setOwner(cfg.ownerName.toStdString()); + bot->setGrace(cfg.ownerGraceMs, cfg.initialGraceMs); + bot->playAs(voice, cfg.key, cfg.bpm, cfg.bpi, cfg.sampleRate, seed); + bots.push_back(std::move(bot)); + + // A different seed per player as well as the salt inside BotBand, so two + // voices cannot land on the same figure by coincidence. + seed = seed * 1664525u + 1013904223u; + } + + // Who arrived together, so the roster can say whether these are a band or + // merely a list. Told before joining, because the announcement happens five + // seconds after connect and nobody should be racing it. + std::vector names; + for (const auto &b : bots) + names.push_back(b->name()); + for (auto &b : bots) + b->setBandmates(names, cfg.bandName.toStdString()); + + for (auto &b : bots) + if (!b->join(std::string(host()), server.port(), cfg.sampleRate)) { + bots.clear(); + server.stop(); + return false; + } + } + + running = true; + conductor.start( + (double)intervalSamples / cfg.sampleRate, [this](int intervalIndex) { + reapPartedBots(); + renderOneInterval(intervalIndex, [this] { return !running.load(); }); + }); + return true; +} + +void PracticeRoom::stop() { + running = false; + + // Joins, and waits as long as it takes rather than to a deadline. + // + // The deadline it replaces was there because a conductor still running is + // one whose bots are about to be destroyed underneath it, and rendering a + // whole interval for four bots means four Vorbis encodes -- not fast under a + // sanitiser. Waiting is the safe half of that trade: `renderOneInterval` + // checks between bots, so the longest this can block is one bot's encode. + conductor.stop(); + + { + juce::ScopedLock sl(botsMutex); + for (auto &b : bots) + b->part(); + bots.clear(); + } + + server.stop(); + intervalSamples = 0; +} + +int PracticeRoom::botCount() const { + juce::ScopedLock sl(botsMutex); + return (int)bots.size(); +} + +juce::StringArray PracticeRoom::botNames() const { + juce::ScopedLock sl(botsMutex); + juce::StringArray names; + for (const auto &b : bots) + names.add(b->name()); + return names; +} + +std::vector PracticeRoom::bandSettings() const { + juce::ScopedLock sl(botsMutex); + std::vector out; + out.reserve(bots.size()); + for (const auto &b : bots) + out.push_back(b->currentSettings()); + return out; +} + +std::vector PracticeRoom::bandPhases() const { + juce::ScopedLock sl(botsMutex); + std::vector out; + out.reserve(bots.size()); + for (const auto &b : bots) + out.push_back(b->playPhase()); + return out; +} + +void PracticeRoom::reapPartedBots() { + // A bot that has parted -- because its owner left, because someone asked it + // to, or because the connection went -- is not coming back. Drop it rather + // than calling into it every interval forever. + juce::ScopedLock sl(botsMutex); + for (int i = (int)bots.size() - 1; i >= 0; --i) + if (!bots[(size_t)i]->isActive()) + bots.erase(bots.begin() + i); +} + +void PracticeRoom::renderOneInterval(int intervalIndex, + const std::function &shouldStop) { + juce::ScopedLock sl(botsMutex); + for (auto &b : bots) { + // Between bots, not just between intervals. One interval of four bots is + // four Vorbis encodes of several seconds of audio each; without a check in + // here, stop() waits for all of them however long that takes, and the + // budget it allows is not the one that matters. + if (shouldStop && shouldStop()) + return; + b->renderInterval(intervalSamples, intervalIndex); + } +} diff --git a/src/PracticeRoom.h b/src/PracticeRoom.h new file mode 100644 index 0000000..64b10cf --- /dev/null +++ b/src/PracticeRoom.h @@ -0,0 +1,120 @@ +#pragma once + +#include +#include +#include +#include "PracticeServer.h" +#include +#include +#include +#include + +// The practice room: a server on the loopback interface, a band of bots +// connected to it, and the port for you to join on. +// +// Practice used to be a mode -- an offline branch through the UI, with its own +// gating and its own strip. Now it is a destination. You connect to it, and +// because everything on the far side is real, the whole connected UI works +// without knowing this room is any different: phase bar, remote strips, +// routing, chat, sync, recording, stems. +// +// The conductor is one thread for the whole band rather than one per bot. Bots +// that share a clock stay tight with each other for free, which is what a band +// is; and one thread is one thing to reason about at teardown. +// +// PHASE: a bot renders interval N during interval N and it is heard in N+1, +// exactly like a player. A bot that *reacts* to you cannot be heard sooner than +// N+2 -- you play in N, it hears you in N+1, the soonest it can send is N+1. +// That is the true latency of the form rather than a limitation here, and it is +// why the echo bot's shallowest delay is two. +class PracticeRoom { +public: + PracticeRoom(); + ~PracticeRoom(); + + struct Config { + int bpm = 120; + int bpi = 8; + double sampleRate = 48000.0; + juce::String ownerName = "you"; + + // What the band calls itself, used once in the arrival roster. + // + // Not an address -- `band`, `everyone` and `all` are the words people + // actually type, and a name would only be a fourth synonym. It earns its + // place in the one line the band gets to introduce itself with, because + // "The Understudies: Mirn (kit), ..." reads as a band arriving where four + // usernames read as four processes starting. + juce::String bandName = "The Understudies"; + juce::String topic = "Practice room -- play, nobody is listening"; + + // What the band plays in. Announcing `[key: D minor]` in chat changes it + // afterwards; this is only where they start. + MusicalKey::Key key = MusicalKey::parseName("C major"); + + // How long the band waits for the owner before leaving for good. + // + // A departure used to be fatal: a PART parted the bot at once, there is no + // reconnect by design, and the room reaped it -- so a thirty-second blip + // destroyed the band and left the room running empty. Three minutes covers + // a router reboot or a client restart; past that it was either deliberate + // or something bigger than a blip. + int ownerGraceMs = 3 * 60 * 1000; + + // Twice as long before the owner has EVER arrived. Starting a room and + // then going to find your instrument is ordinary, and a band that has + // never seen anybody is costing nothing while it waits -- it arrives + // silent. This exists so a forgotten room does not sit on a real server + // for ever, not to hurry anybody. + int initialGraceMs = 6 * 60 * 1000; + + // Rerolled by "shake". Fixed by default so a practice room is the same + // room twice, which matters more for learning a piece than novelty does. + std::uint32_t seed = 20260811u; + }; + + // Brings up the server and the band. Returns false having cleaned up if the + // room could not be started. + bool start(const Config &config); + void stop(); + + bool isRunning() const { return running.load(); } + + // Loopback only, always. Nothing here ever hands out another address. + static const char *host() { return "127.0.0.1"; } + int port() const { return server.port(); } + + int botCount() const; + juce::StringArray botNames() const; + + // What each bot is currently playing. For tests and for the UI to report the + // key and chords the band has settled on. + std::vector bandSettings() const; + + // What each bot is playing, or how far through stopping it is. The observable + // for the play/stop states: from outside, the difference between wrapping up + // and being silent is several seconds of audio, which no test can watch. + std::vector bandPhases() const; + + PracticeServer &practiceServer() { return server; } + +private: + // Drives every bot's interval render in step. The loop itself is + // `jambot::Conductor`, which is JUCE-free because a band on a command line + // needs exactly the same counting. + + void renderOneInterval(int intervalIndex, + const std::function &shouldStop); + void reapPartedBots(); + + PracticeServer server; + std::vector> bots; + mutable juce::CriticalSection botsMutex; + + jambot::Conductor conductor; + Config cfg; + std::atomic running{false}; + int intervalSamples = 0; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PracticeRoom) +}; diff --git a/src/PracticeServer.cpp b/src/PracticeServer.cpp new file mode 100644 index 0000000..4158310 --- /dev/null +++ b/src/PracticeServer.cpp @@ -0,0 +1,515 @@ +#include "PracticeServer.h" + +#include "SocketWrite.h" + +PracticeServer::PracticeServer() : juce::Thread("PracticeServer") {} + +PracticeServer::~PracticeServer() { stop(); } + +bool PracticeServer::start(int bpmIn, int bpiIn) { + serverBpm = bpmIn; + serverBpi = bpiIn; + + // 127.0.0.1 explicitly, never INADDR_ANY. The room must not be reachable from + // anywhere but this machine -- see the class comment. + if (!listener.createListener(0, "127.0.0.1")) + return false; + + const int p = listener.getBoundPort(); + if (p <= 0) { + listener.close(); + return false; + } + boundPort = p; + startThread(); + return true; +} + +void PracticeServer::stop() { + signalThreadShouldExit(); + listener.close(); + { + juce::ScopedLock sl(clientsMutex); + for (auto &c : clients) + if (c->socket) + c->socket->close(); + } + // A thread that misses this deadline is one whose WaitableEvents ~Thread() is + // about to destroy underneath it. FakeNinjamServer learned this the hard way. + if (!stopThread(2000)) { + std::fprintf(stderr, "PracticeServer: thread did not exit within 2000ms; " + "destroying it now is unsafe\n"); + std::fflush(stderr); + } + { + juce::ScopedLock sl(clientsMutex); + clients.clear(); + } + boundPort = 0; +} + +int PracticeServer::bpm() const { return serverBpm.load(); } +int PracticeServer::bpi() const { return serverBpi.load(); } + +void PracticeServer::setConfig(int bpmIn, int bpiIn) { + serverBpm = bpmIn; + serverBpi = bpiIn; + auto p = NinjamProtocol::buildServerConfig(bpmIn, bpiIn); + juce::ScopedLock sl(clientsMutex); + broadcastExceptLocked(nullptr, 0x02, p.data(), (int)p.size()); +} + +void PracticeServer::setTopic(const juce::String &topic) { + { + juce::ScopedLock sl(stateMutex); + roomTopic = topic; + } + auto p = NinjamProtocol::buildChat("TOPIC", {}, topic.toStdString()); + juce::ScopedLock sl(clientsMutex); + broadcastExceptLocked(nullptr, 0xC0, p.data(), (int)p.size()); +} + +void PracticeServer::broadcastChat(const juce::String &from, + const juce::String &text) { + auto p = + NinjamProtocol::buildChat("MSG", from.toStdString(), text.toStdString()); + juce::ScopedLock sl(clientsMutex); + broadcastExceptLocked(nullptr, 0xC0, p.data(), (int)p.size()); +} + +int PracticeServer::clientCount() const { + juce::ScopedLock sl(clientsMutex); + return (int)clients.size(); +} + +juce::StringArray PracticeServer::connectedUsernames() const { + juce::ScopedLock sl(clientsMutex); + juce::StringArray names; + for (const auto &c : clients) + if (c->authenticated) + names.add(c->username); + return names; +} + +// --------------------------------------------------------------------------- +// Sending +// --------------------------------------------------------------------------- + +bool PracticeServer::sendTo(Client &c, juce::uint8 type, const void *data, + int size) { + if (!c.socket || !c.socket->isConnected()) + return false; + + juce::uint8 header[NinjamProtocol::kHeaderSize]; + NinjamProtocol::writeFrameHeader(header, type, (juce::uint32)size); + if (SocketWrite::noSigPipe(*c.socket, header, NinjamProtocol::kHeaderSize) != + NinjamProtocol::kHeaderSize) + return false; + if (size > 0 && SocketWrite::noSigPipe(*c.socket, data, size) != size) + return false; + return true; +} + +void PracticeServer::broadcastExceptLocked(const Client *skip, juce::uint8 type, + const void *data, int size) { + for (auto &c : clients) { + if (c.get() == skip || !c->authenticated) + continue; + sendTo(*c, type, data, size); + } +} + +bool PracticeServer::subscribed(const Client &to, const juce::String &user, + int channelIndex) { + // Channel indices at or above 32 have no bit; buildUsermask drops them + // rather than shifting past the width of the mask. + if (channelIndex < 0 || channelIndex >= 32) + return false; + auto it = to.usermask.find(user.toStdString()); + if (it == to.usermask.end()) + return false; + return (it->second & (1u << channelIndex)) != 0; +} + +void PracticeServer::relayAudioLocked(const Client &from, int channelIndex, + juce::uint8 type, const void *data, + int size) { + for (auto &c : clients) { + if (c.get() == &from || !c->authenticated) + continue; + + // Not subscribed: send nothing. This is the whole reason bots are cheap -- + // a deaf bot never causes an interval buffer to be allocated at the far + // end, and a room of four costs one client's worth of memory, not five. + // Note that an unsubscribed client sends a mask of zero rather than + // omitting the entry, so presence in the map is not consent. + if (!subscribed(*c, from.username, channelIndex)) + continue; + + // An audio frame is large enough to fill a socket buffer, and a blocking + // write here would stall the whole room. Dropping is safe where blocking is + // not: interval delivery is all-or-nothing, so a client that misses part of + // an interval simply does not play it -- exactly what happens on a real + // network under loss. + if (c->socket == nullptr || c->socket->waitUntilReady(false, 0) <= 0) + continue; + + sendTo(*c, type, data, size); + } +} + +// --------------------------------------------------------------------------- +// Room bookkeeping +// --------------------------------------------------------------------------- + +juce::String PracticeServer::uniqueUsername(const juce::String &wanted) const { + // Caller holds clientsMutex. Two players with one name would collide in + // NinjamClient's (username, channelIndex) slot key and mix into each other. + const juce::String base = wanted.isEmpty() ? juce::String("player") : wanted; + juce::String candidate = base; + int suffix = 1; + bool clash = true; + while (clash) { + clash = false; + for (const auto &c : clients) + if (c->authenticated && c->username == candidate) { + clash = true; + break; + } + if (clash) + candidate = base + juce::String(++suffix); + } + return candidate; +} + +void PracticeServer::sendRoster(Client &to) { + // Caller holds clientsMutex. + std::vector entries; + for (const auto &c : clients) { + if (c.get() == &to || !c->authenticated) + continue; + for (const auto &[idx, name] : c->channels) { + NinjamProtocol::UserInfoEntry e; + e.active = true; + e.channelIndex = idx; + e.username = c->username.toStdString(); + e.channelName = name.toStdString(); + entries.push_back(std::move(e)); + } + } + if (entries.empty()) + return; + + auto p = NinjamProtocol::buildUserInfo(entries); + sendTo(to, 0x03, p.data(), (int)p.size()); +} + +void PracticeServer::broadcastChannels( + const juce::String &username, const std::map &channels, + bool active, const Client *skip) { + // Caller holds clientsMutex. + std::vector entries; + for (const auto &[idx, name] : channels) { + NinjamProtocol::UserInfoEntry e; + e.active = active; + e.channelIndex = idx; + e.username = username.toStdString(); + e.channelName = name.toStdString(); + entries.push_back(std::move(e)); + } + if (entries.empty()) + return; + + auto p = NinjamProtocol::buildUserInfo(entries); + for (auto &other : clients) { + if (other.get() == skip || !other->authenticated) + continue; + sendTo(*other, 0x03, p.data(), (int)p.size()); + } +} + +// --------------------------------------------------------------------------- +// The thread +// --------------------------------------------------------------------------- + +void PracticeServer::run() { + while (!threadShouldExit()) { + acceptPendingConnections(); + + bool didWork = false; + { + juce::ScopedLock sl(clientsMutex); + for (int i = (int)clients.size() - 1; i >= 0; --i) { + auto &c = *clients[(size_t)i]; + if (c.socket == nullptr || !c.socket->isConnected()) { + dropClient(i); + continue; + } + if (c.socket->waitUntilReady(true, 0) <= 0) + continue; + if (!readFromClient(c)) { + dropClient(i); + continue; + } + didWork = true; + drainFrames(c); + } + } + + // Poll rather than block: waitForNextConnection waits forever and closing + // the listener from another thread does not reliably wake it, which is the + // same trap NinjamClient::readFull and FakeNinjamServer both hit. + if (!didWork) + wait(5); + } +} + +void PracticeServer::acceptPendingConnections() { + if (listener.waitUntilReady(true, 0) <= 0) + return; + + auto *accepted = listener.waitForNextConnection(); + if (accepted == nullptr) + return; + + auto client = std::make_unique(); + client->socket.reset(accepted); + SocketWrite::prepare(*client->socket); + for (int i = 0; i < 8; ++i) + client->challenge[i] = (juce::uint8)rng.nextInt(256); + + auto p = NinjamProtocol::buildAuthChallenge(client->challenge); + // The server speaks first. + sendTo(*client, 0x00, p.data(), (int)p.size()); + + juce::ScopedLock sl(clientsMutex); + clients.push_back(std::move(client)); +} + +void PracticeServer::dropClient(int index) { + // Caller holds clientsMutex. + auto &c = *clients[(size_t)index]; + if (c.authenticated) { + broadcastChannels(c.username, c.channels, false, &c); + + // PART, not a MSG saying so: NinjamClient only removes a name from + // roomMembers on a real PART (NinjamClient.cpp:652), and a bot that leaves + // when its owner does needs that to be accurate. + auto part = NinjamProtocol::buildChat("PART", c.username.toStdString()); + for (auto &other : clients) { + if (other.get() == &c || !other->authenticated) + continue; + sendTo(*other, 0xC0, part.data(), (int)part.size()); + } + } + clients.erase(clients.begin() + index); +} + +bool PracticeServer::readFromClient(Client &c) { + char buf[8192]; + const int got = c.socket->read(buf, (int)sizeof(buf), false); + if (got <= 0) + return false; + c.pending.append(buf, (size_t)got); + return true; +} + +void PracticeServer::drainFrames(Client &c) { + // Caller holds clientsMutex. + size_t offset = 0; + while (true) { + const size_t avail = c.pending.getSize() - offset; + if (avail < (size_t)NinjamProtocol::kHeaderSize) + break; + + const auto *base = static_cast(c.pending.getData()); + NinjamProtocol::FrameHeader frame; + if (!NinjamProtocol::readFrameHeader(base + offset, frame)) { + // Oversized length: the stream is desynchronised and cannot be recovered. + c.socket->close(); + return; + } + + const size_t total = (size_t)NinjamProtocol::kHeaderSize + frame.length; + if (avail < total) + break; + + ByteBuffer payload; + if (frame.length > 0) { + const auto *start = base + offset + NinjamProtocol::kHeaderSize; + payload.assign(start, start + frame.length); + } + + offset += total; + handleFrame(c, frame.type, payload); + } + + if (offset > 0) + c.pending.removeSection(0, offset); +} + +void PracticeServer::handleFrame(Client &c, juce::uint8 type, + const ByteBuffer &payload) { + // Caller holds clientsMutex. + switch (type) { + case 0x80: { // CLIENT_AUTH_USER + NinjamProtocol::AuthUser au; + if (!NinjamProtocol::parseAuthUser(payload, au)) { + c.socket->close(); + return; + } + + // Any password is accepted: this room is on the loopback interface and + // exists to be walked into. Rejecting one would only be theatre. + c.username = uniqueUsername(juce::String(au.username)); + c.authenticated = true; + + // The cap matters: the reference client stores it as m_max_localch and + // silently refuses to transmit on any channel index at or above it, so a + // reply without it gets no audio at all (njclient.cpp:1096). + auto reply = NinjamProtocol::buildAuthReply(true, {}, 32); + sendTo(c, 0x01, reply.data(), (int)reply.size()); + + auto cfg = + NinjamProtocol::buildServerConfig(serverBpm.load(), serverBpi.load()); + sendTo(c, 0x02, cfg.data(), (int)cfg.size()); + + juce::String topic; + { + juce::ScopedLock sl(stateMutex); + topic = roomTopic; + } + if (topic.isNotEmpty()) { + auto t = NinjamProtocol::buildChat("TOPIC", {}, topic.toStdString()); + sendTo(c, 0xC0, t.data(), (int)t.size()); + } + + // Who is already here, then tell everyone else who just arrived. JOIN and + // PART are how the far end maintains room membership for players who have + // no audio channels at all. + for (const auto &other : clients) { + if (other.get() == &c || !other->authenticated) + continue; + auto j = NinjamProtocol::buildChat("JOIN", other->username.toStdString()); + sendTo(c, 0xC0, j.data(), (int)j.size()); + } + + auto joined = NinjamProtocol::buildChat("JOIN", c.username.toStdString()); + for (auto &other : clients) { + if (other.get() == &c || !other->authenticated) + continue; + sendTo(*other, 0xC0, joined.data(), (int)joined.size()); + } + + sendRoster(c); + return; + } + + case 0x81: { // CLIENT_SET_USERMASK + std::vector masks; + NinjamProtocol::parseUsermask(payload, masks); + for (const auto &m : masks) + c.usermask[m.username] = m.mask; + return; + } + + case 0x82: { // CLIENT_SET_CHANNEL_INFO + std::vector chans; + if (!NinjamProtocol::parseChannelInfo(payload, chans)) + return; + + // A channel that has gone is announced as inactive before the map forgets + // it, or the far end keeps a strip for a channel nobody is sending on. + std::map departed; + for (const auto &[idx, name] : c.channels) + if ((size_t)idx >= chans.size()) + departed[idx] = name; + if (!departed.empty()) + broadcastChannels(c.username, departed, false, &c); + + c.channels.clear(); + for (size_t i = 0; i < chans.size(); ++i) + c.channels[(int)i] = chans[i].name; + + broadcastChannels(c.username, c.channels, true, &c); + return; + } + + case 0x83: { // UPLOAD_INTERVAL_BEGIN -> DOWNLOAD_INTERVAL_BEGIN + NinjamProtocol::IntervalBegin begin; + if (!NinjamProtocol::parseIntervalBegin(payload, begin)) + return; + + c.uploadChannel[begin.guidHex] = begin.channelIndex; + auto out = NinjamProtocol::buildIntervalBegin( + begin.guid, begin.estimatedSize, begin.fourcc, begin.channelIndex, + c.username.toStdString()); + relayAudioLocked(c, begin.channelIndex, 0x04, out.data(), (int)out.size()); + return; + } + + case 0x84: { // UPLOAD_INTERVAL_WRITE -> DOWNLOAD_INTERVAL_WRITE + NinjamProtocol::IntervalWrite w; + if (!NinjamProtocol::parseIntervalWrite(payload, w)) + return; + + // A write for a GUID we never saw a begin for cannot be attributed to a + // channel, so it cannot be filtered, so it is dropped. + auto it = c.uploadChannel.find(w.guidHex); + if (it == c.uploadChannel.end()) + return; + const int channelIndex = it->second; + if (w.isFinal) + c.uploadChannel.erase(it); + + // The 0x84 and 0x05 payloads are byte-identical, so this is a forward. + relayAudioLocked(c, channelIndex, 0x05, payload.data(), + (int)payload.size()); + return; + } + + case 0xC0: { // CHAT_MESSAGE + NinjamProtocol::Chat chat; + if (!NinjamProtocol::parseChat(payload, chat)) + return; + + if (chat.type == "MSG") { + auto out = + NinjamProtocol::buildChat("MSG", c.username.toStdString(), chat.p1); + for (auto &other : clients) { + if (!other->authenticated) + continue; + sendTo(*other, 0xC0, out.data(), (int)out.size()); + } + return; + } + + if (chat.type == "PRIVMSG") { + auto out = NinjamProtocol::buildChat("PRIVMSG", c.username.toStdString(), + chat.p2); + for (auto &other : clients) + if (other->authenticated && other->username == juce::String(chat.p1)) + sendTo(*other, 0xC0, out.data(), (int)out.size()); + return; + } + + if (chat.type == "TOPIC") { + { + juce::ScopedLock sl(stateMutex); + roomTopic = chat.p2; + } + auto out = + NinjamProtocol::buildChat("TOPIC", c.username.toStdString(), chat.p2); + for (auto &other : clients) + if (other->authenticated) + sendTo(*other, 0xC0, out.data(), (int)out.size()); + return; + } + return; + } + + case 0xFD: // KEEP_ALIVE -- nothing to do, the read itself proved liveness. + default: + return; + } +} diff --git a/src/PracticeServer.h b/src/PracticeServer.h new file mode 100644 index 0000000..3a82317 --- /dev/null +++ b/src/PracticeServer.h @@ -0,0 +1,123 @@ +#pragma once + +#include "NinjamProtocol.h" +#include +#include +#include +#include +#include + +// A real Ninjam server, in process, on the loopback interface. +// +// This is what makes practice mode a jam rather than a simulation of one: +// NinjamClient keys remote players purely off the wire, so a room served from +// here lights up the whole connected UI -- phase bar, remote strips, routing, +// chat, sync, recording -- with no special-casing anywhere. +// +// It is small because Ninjam servers are small. The interval grid is entirely +// client-side (every client plays each received interval starting at its own +// downbeat, PRINCIPLES 9), so there is no clock here at all. The server +// authenticates, tracks who is in the room, and relays. +// +// Not a general-purpose server, and not trying to be: no licences, no +// persistence, no anonymous-user rules, no bans. It serves a practice room. +// +// SAFETY: the listener binds 127.0.0.1 explicitly and nothing else. That is the +// property that replaces the old practice echo's "offline by construction" +// argument (DESIGN.md 6.2) now that practising means being genuinely connected +// and genuinely transmitting. +class PracticeServer : private juce::Thread { +public: + PracticeServer(); + ~PracticeServer() override; + + bool start(int bpm = 120, int bpi = 8); + void stop(); + + int port() const { return boundPort.load(); } + bool isListening() const { return boundPort.load() > 0; } + + // Broadcasts SERVER_CONFIG_CHANGE. Safe from any thread. + void setConfig(int bpm, int bpi); + int bpm() const; + int bpi() const; + + void setTopic(const juce::String &topic); + + // Relayed as though `from` had typed it, so the client renders it through the + // ordinary chat path. `from` empty means the server itself. + void broadcastChat(const juce::String &from, const juce::String &text); + + juce::StringArray connectedUsernames() const; + int clientCount() const; + +private: + struct Client { + std::unique_ptr socket; + juce::String username; + bool authenticated = false; + juce::uint8 challenge[8] = {}; + + // Channel index -> name, as last declared by CLIENT_SET_CHANNEL_INFO. + std::map channels; + + // Who this client has asked to hear, by CLIENT_SET_USERMASK: a bit per + // channel index. Absent from the map and present-but-zero both mean "send + // me nothing", which is how a bot stays deaf and why the room does not cost + // a NinjamClient's worth of interval buffers per bot. + std::map usermask; + + // GUID -> channel index for this client's uploads in flight. Only + // UPLOAD_INTERVAL_BEGIN carries the channel index; the writes that follow + // identify themselves by GUID alone, so the relay has to remember which + // channel each one belongs to in order to honour a subscription. + std::map uploadChannel; + + // Frames arrive split across reads and coalesced across writes, so bytes + // accumulate here until a whole frame is present. + juce::MemoryBlock pending; + }; + + void run() override; + void acceptPendingConnections(); + bool readFromClient(Client &c); + void drainFrames(Client &c); + void handleFrame(Client &c, juce::uint8 type, + const chalkwalk::ninjam::ByteBuffer &payload); + void dropClient(int index); + + // Control frames are written blocking: they are small, they always fit, and + // losing one desynchronises the room. Audio frames go through relayAudio, + // which drops rather than blocks -- see the comment there. + // The Locked suffix means the caller already holds clientsMutex. The frame + // handler runs with it held and needs to relay from inside that, so these + // must not take it again: juce::CriticalSection is recursive and would + // permit it, but a lock whose depth depends on the call path is a lock + // nobody can reason about -- and TSan does not model the recursion, so it + // reports every such acquisition. + bool sendTo(Client &c, juce::uint8 type, const void *data, int size); + void relayAudioLocked(const Client &from, int channelIndex, juce::uint8 type, + const void *data, int size); + static bool subscribed(const Client &to, const juce::String &user, + int channelIndex); + void broadcastExceptLocked(const Client *skip, juce::uint8 type, + const void *data, int size); + + void sendRoster(Client &to); + void broadcastChannels(const juce::String &username, + const std::map &channels, + bool active, const Client *skip); + juce::String uniqueUsername(const juce::String &wanted) const; + + juce::StreamingSocket listener; + std::vector> clients; + mutable juce::CriticalSection clientsMutex; + + std::atomic boundPort{0}; + std::atomic serverBpm{120}; + std::atomic serverBpi{8}; + juce::String roomTopic; + mutable juce::CriticalSection stateMutex; + + juce::Random rng; +}; diff --git a/src/RoomHarmony.h b/src/RoomHarmony.h new file mode 100644 index 0000000..057b514 --- /dev/null +++ b/src/RoomHarmony.h @@ -0,0 +1,41 @@ +#pragma once + +#include "MusicalKey.h" + +#include "Harmony.h" +#include + +#include + +// Which of the two a chat line is, and nothing else. +// +// The subset of chat that needs no address, because its SYNTAX is unmistakable: +// a `[key: Dm]` tag, a `| Am | F |` chart, or a degree chart against the key +// the room is already in. Nobody writes any of them by accident. +// +// What each MEANS is `Harmony::Session` in chalkwalk-music -- preserve what was +// written, re-derive what was delegated -- and how a key TRAVELS is +// `chalkwalk::ninjam::conventions`. This is the seven lines that put the two +// together, and it is deliberately nothing more: the band has the same seven +// lines inside `PracticeBot`, because duplicating a dispatch is cheaper than +// giving glue a home of its own, and because what must not be duplicated -- +// the rule and the convention -- is not. +namespace RoomHarmony { + +using State = Harmony::Session; +enum class Change { None, Key, Chart }; + +inline Change apply(const std::string &line, State &state) { + if (const auto keyName = + chalkwalk::ninjam::conventions::extractKeyAnnouncement(line); + !keyName.empty()) + return Harmony::applyKey(keyName, state) == Harmony::Applied::Key + ? Change::Key + : Change::None; + + return Harmony::applyChart(line, state) == Harmony::Applied::Chart + ? Change::Chart + : Change::None; +} + +} // namespace RoomHarmony diff --git a/src/Sha1.cpp b/src/Sha1.cpp deleted file mode 100644 index 298b555..0000000 --- a/src/Sha1.cpp +++ /dev/null @@ -1,99 +0,0 @@ -#include "Sha1.h" -#include - -static uint32_t rotl32(uint32_t v, int n) { return (v << n) | (v >> (32 - n)); } - -static uint32_t beu32(const uint8_t *p) { - return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | - ((uint32_t)p[2] << 8) | (uint32_t)p[3]; -} - -Sha1::Sha1() { - h[0] = 0x67452301u; - h[1] = 0xEFCDAB89u; - h[2] = 0x98BADCFEu; - h[3] = 0x10325476u; - h[4] = 0xC3D2E1F0u; - byteCount = 0; -} - -void Sha1::processBlock(const uint8_t *block) { - uint32_t w[80]; - for (int i = 0; i < 16; ++i) - w[i] = beu32(block + i * 4); - for (int i = 16; i < 80; ++i) - w[i] = rotl32(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1); - - uint32_t a = h[0], b = h[1], c = h[2], d = h[3], e = h[4]; - for (int i = 0; i < 80; ++i) { - uint32_t f, k; - if (i < 20) { - f = (b & c) | (~b & d); - k = 0x5A827999u; - } else if (i < 40) { - f = b ^ c ^ d; - k = 0x6ED9EBA1u; - } else if (i < 60) { - f = (b & c) | (b & d) | (c & d); - k = 0x8F1BBCDCu; - } else { - f = b ^ c ^ d; - k = 0xCA62C1D6u; - } - uint32_t t = rotl32(a, 5) + f + e + k + w[i]; - e = d; - d = c; - c = rotl32(b, 30); - b = a; - a = t; - } - h[0] += a; - h[1] += b; - h[2] += c; - h[3] += d; - h[4] += e; -} - -void Sha1::add(const void *data, int len) { - const uint8_t *p = static_cast(data); - int bufFill = static_cast(byteCount & 63); - byteCount += static_cast(len); - while (len > 0) { - int space = 64 - bufFill; - int take = len < space ? len : space; - memcpy(buf + bufFill, p, static_cast(take)); - p += take; - len -= take; - bufFill += take; - if (bufFill == 64) { - processBlock(buf); - bufFill = 0; - } - } -} - -void Sha1::result(void *out) { - uint64_t bits = byteCount * 8; - uint8_t pad = 0x80; - add(&pad, 1); - uint8_t zero = 0; - while ((byteCount & 63) != 56) - add(&zero, 1); - uint8_t lenBytes[8]; - for (int i = 7; i >= 0; --i) { - lenBytes[i] = static_cast(bits & 0xFF); - bits >>= 8; - } - add(lenBytes, 8); - - uint8_t *o = static_cast(out); - for (int i = 0; i < 5; ++i) { - o[i * 4 + 0] = static_cast(h[i] >> 24); - o[i * 4 + 1] = static_cast(h[i] >> 16); - o[i * 4 + 2] = static_cast(h[i] >> 8); - o[i * 4 + 3] = static_cast(h[i]); - } - - // reset for reuse - *this = Sha1(); -} diff --git a/src/Sha1.h b/src/Sha1.h index 9be84b9..69d5942 100644 --- a/src/Sha1.h +++ b/src/Sha1.h @@ -1,14 +1,11 @@ #pragma once -#include -class Sha1 { -public: - Sha1(); - void add(const void *data, int len); - void result(void *out); // writes 20 bytes; resets state -private: - uint32_t h[5]; - uint8_t buf[64]; - uint64_t byteCount; - void processBlock(const uint8_t *block); -}; +// Adopted from chalkwalk-ninjam (libs/ninjam, MIT). +// +// This header exists only so that call sites keep saying `Sha1` rather than +// `chalkwalk::ninjam::Sha1`. The implementation, its comments and its tests +// all live in the library now. + +#include + +using chalkwalk::ninjam::Sha1; diff --git a/src/SocketWrite.h b/src/SocketWrite.h new file mode 100644 index 0000000..8a6d6c0 --- /dev/null +++ b/src/SocketWrite.h @@ -0,0 +1,71 @@ +#pragma once + +#include + +#if JUCE_LINUX || JUCE_BSD || JUCE_MAC +#include +#endif + +// Writing to a socket whose peer has already closed must return an error, not +// kill the process. +// +// juce::StreamingSocket::write calls ::send with no flags (juce_Socket.cpp:532) +// and JUCE only suppresses SIGPIPE for named pipes, so on Linux the default +// disposition terminates the host -- a DAW -- the first time a player leaves a +// room mid-write. It is a narrow race, which is why it survives casual testing: +// PracticeServer provokes it reliably because it writes to several peers that +// come and go independently. +// +// The alternative, ignoring SIGPIPE process-wide, is what a standalone server +// would do, but a plugin does not get to change its host's signal disposition. +// So the suppression is per-write and platform-guarded instead. +// +// This is the only platform-specific code in src/. It is here rather than +// inlined at the call site so there is exactly one place to revisit if JUCE +// ever grows the flag itself. +namespace SocketWrite { + +// Returns the number of bytes written, or -1 on error, matching +// juce::StreamingSocket::write. +inline int noSigPipe(juce::StreamingSocket &socket, const void *data, + int numBytes) { + if (numBytes <= 0) + return 0; + +#if JUCE_LINUX || JUCE_BSD + const int fd = socket.getRawSocketHandle(); + if (fd < 0) + return -1; + + auto *p = static_cast(data); + int written = 0; + while (written < numBytes) { + const auto n = + ::send(fd, p + written, (size_t)(numBytes - written), MSG_NOSIGNAL); + if (n <= 0) + return written > 0 ? written : -1; + written += (int)n; + } + return written; +#else + // macOS carries SO_NOSIGPIPE on the socket itself (set by prepare below) and + // Windows has no SIGPIPE at all, so the ordinary path is already safe. + return socket.write(data, numBytes); +#endif +} + +// Call once per accepted or connected socket. A no-op where the option does +// not exist. +inline void prepare(juce::StreamingSocket &socket) { +#if JUCE_MAC || JUCE_BSD + const int fd = socket.getRawSocketHandle(); + if (fd >= 0) { + const int on = 1; + ::setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on)); + } +#else + juce::ignoreUnused(socket); +#endif +} + +} // namespace SocketWrite diff --git a/src/SpscRing.h b/src/SpscRing.h index 3d300a5..bbec8ec 100644 --- a/src/SpscRing.h +++ b/src/SpscRing.h @@ -1,86 +1,11 @@ #pragma once -#include -#include -#include - -// A single-producer, single-consumer ring of pointers. -// -// The primitive the RX path needs to stop taking a lock on the audio thread. -// Two rings per stream carry ownership in a circle without either side ever -// waiting for the other: -// -// ready: network thread -> audio thread ("here is a decoded interval") -// retired: audio thread -> network thread ("done with this one, free it") -// -// The retire direction is the load-bearing half. The audio thread must never -// drop the last reference to a DecodedInterval, because that frees a -// multi-megabyte buffer inside the callback. Handing the pointer back and -// letting the owning thread release it keeps deallocation off the audio thread -// entirely. +// Adopted from chalkwalk-ninjam (libs/ninjam, MIT). // -// Deliberately holds raw pointers, not shared_ptr: copying a shared_ptr touches -// an atomic refcount, and destroying one can free. Ownership lives in the -// producer's own container for the whole time a pointer is in flight. -// -// Exactly one thread may push and exactly one may pop. With more of either the -// index arithmetic is wrong, and nothing here will tell you. - -template class SpscRing { -public: - static_assert(Capacity > 1, "a one-slot ring is always either full or empty"); - - // Producer side. False when full, which the caller must handle -- dropping - // is usually right on an audio path, and is always better than blocking. - bool push(T *value) { - const int w = writeIndex.load(std::memory_order_relaxed); - const int next = advance(w); - // acquire pairs with the consumer's release, so a slot freed by pop() is - // visible here before it is reused. - if (next == readIndex.load(std::memory_order_acquire)) - return false; // full - - items[(std::size_t)w] = value; - // release publishes the slot's contents along with the new index. - writeIndex.store(next, std::memory_order_release); - return true; - } - - // Consumer side. Null when empty. - T *pop() { - const int r = readIndex.load(std::memory_order_relaxed); - if (r == writeIndex.load(std::memory_order_acquire)) - return nullptr; // empty - - T *value = items[(std::size_t)r]; - items[(std::size_t)r] = nullptr; - readIndex.store(advance(r), std::memory_order_release); - return value; - } - - // Consumer side, and only meaningful there: the producer may fill it the - // instant after this returns. - bool isEmpty() const { - return readIndex.load(std::memory_order_acquire) == - writeIndex.load(std::memory_order_acquire); - } - - // Approximate, for diagnostics only. - int sizeApprox() const { - const int w = writeIndex.load(std::memory_order_acquire); - const int r = readIndex.load(std::memory_order_acquire); - return w >= r ? w - r : Capacity + 1 - r + w; - } - - static constexpr int capacity() { return Capacity; } +// One producer, one consumer, no locks. The single-writer/single-reader +// requirement and what happens if you break it are documented on the library +// header, along with the TSan run that checks it. -private: - // One slot is always left empty so full and empty are distinguishable - // without a separate count, which would need its own synchronisation. - static constexpr int Slots = Capacity + 1; - static int advance(int i) { return i + 1 == Slots ? 0 : i + 1; } +#include - std::array items{}; - std::atomic writeIndex{0}; - std::atomic readIndex{0}; -}; +using chalkwalk::ninjam::SpscRing; diff --git a/src/VorbisCodec.cpp b/src/VorbisCodec.cpp deleted file mode 100644 index e152764..0000000 --- a/src/VorbisCodec.cpp +++ /dev/null @@ -1,282 +0,0 @@ -#include "VorbisCodec.h" - -#include -#include -#include - -#include -#include -#include - -// --------------------------------------------------------------------------- -// VorbisDecoder -// --------------------------------------------------------------------------- - -struct VorbisDecoder::Impl { - ogg_sync_state oy; - ogg_stream_state os; - ogg_page og; - ogg_packet op; - vorbis_info vi; - vorbis_comment vc; - vorbis_dsp_state vd; - vorbis_block vb; - - int packetsSeen = 0; - bool streamInited = false; - bool dspInited = false; - - std::vector outBuf; - size_t readOffset = 0; - - Impl() { - memset(&oy, 0, sizeof(oy)); - memset(&os, 0, sizeof(os)); - memset(&og, 0, sizeof(og)); - memset(&op, 0, sizeof(op)); - memset(&vi, 0, sizeof(vi)); - memset(&vc, 0, sizeof(vc)); - memset(&vd, 0, sizeof(vd)); - memset(&vb, 0, sizeof(vb)); - ogg_sync_init(&oy); - } - - ~Impl() { - if (dspInited) { - vorbis_block_clear(&vb); - vorbis_dsp_clear(&vd); - } - vorbis_comment_clear(&vc); - vorbis_info_clear(&vi); - if (streamInited) - ogg_stream_clear(&os); - ogg_sync_clear(&oy); - } - - void compact() { - if (readOffset > outBuf.size() / 2 && readOffset > 0) { - outBuf.erase(outBuf.begin(), - outBuf.begin() + static_cast(readOffset)); - readOffset = 0; - } - } - - void decode(const void *data, int len) { - char *buf = ogg_sync_buffer(&oy, len); - if (!buf) - return; - memcpy(buf, data, static_cast(len)); - ogg_sync_wrote(&oy, len); - - while (ogg_sync_pageout(&oy, &og) > 0) { - int serial = ogg_page_serialno(&og); - if (!streamInited) { - ogg_stream_init(&os, serial); - streamInited = true; - vorbis_info_init(&vi); - vorbis_comment_init(&vc); - } - ogg_stream_pagein(&os, &og); - - while (ogg_stream_packetout(&os, &op) > 0) { - if (packetsSeen < 3) { - if (vorbis_synthesis_headerin(&vi, &vc, &op) < 0) - return; - ++packetsSeen; - if (packetsSeen == 3) { - vorbis_synthesis_init(&vd, &vi); - vorbis_block_init(&vd, &vb); - dspInited = true; - } - } else { - float **pcm; - if (vorbis_synthesis(&vb, &op) == 0) - vorbis_synthesis_blockin(&vd, &vb); - int samples; - while ((samples = vorbis_synthesis_pcmout(&vd, &pcm)) > 0) { - int ch = vi.channels; - size_t base = outBuf.size(); - outBuf.resize(base + static_cast(samples * ch)); - float *dst = outBuf.data() + base; - for (int n = 0; n < samples; ++n) - for (int c = 0; c < ch; ++c) - *dst++ = pcm[c][n]; - vorbis_synthesis_read(&vd, samples); - } - } - } - } - compact(); - } -}; - -VorbisDecoder::VorbisDecoder() : p(std::make_unique()) {} -VorbisDecoder::~VorbisDecoder() = default; - -void VorbisDecoder::decode(const void *data, int len) { p->decode(data, len); } - -int VorbisDecoder::available() const { - return static_cast(p->outBuf.size() - p->readOffset); -} - -const float *VorbisDecoder::pcm() const { - return p->outBuf.data() + p->readOffset; -} - -void VorbisDecoder::skip(int count) { - p->readOffset = - std::min(p->readOffset + static_cast(count), p->outBuf.size()); - p->compact(); -} - -int VorbisDecoder::sampleRate() const { return p->vi.rate; } -int VorbisDecoder::numChannels() const { - return p->vi.channels ? p->vi.channels : 1; -} - -// --------------------------------------------------------------------------- -// VorbisEncoder -// --------------------------------------------------------------------------- - -// Piecewise-linear kbps -> VBR quality mapping ported from WDL vorbisencdec.h. -static float bitrateToQuality(int kbps) { - float qv; - if (kbps < 40) - qv = -0.1f; - else if (kbps < 64) - qv = -0.10f + (kbps - 40) * (0.10f / 24.0f); - else if (kbps < 75) - qv = (kbps - 64) * (0.1f / 9.0f); - else if (kbps < 95) - qv = 0.1f + (kbps - 75) * (0.2f / 20.0f); - else if (kbps < 110) - qv = 0.3f + (kbps - 95) * (0.2f / 15.0f); - else if (kbps < 140) - qv = 0.5f + (kbps - 110) * (0.25f / 30.0f); - else - qv = 0.75f + (kbps - 140) * (0.25f / 100.0f); - if (qv < -0.1f) - qv = -0.1f; - if (qv > 1.0f) - qv = 1.0f; - return qv; -} - -struct VorbisEncoder::Impl { - ogg_stream_state os; - vorbis_info vi; - vorbis_comment vc; - vorbis_dsp_state vd; - vorbis_block vb; - - int nch; - bool ok = false; - - std::vector outBuf; - size_t readOffset = 0; - - Impl(int sampleRate, int numChannels, int bitrateKbps, int serialNumber) - : nch(numChannels) { - memset(&os, 0, sizeof(os)); - memset(&vi, 0, sizeof(vi)); - memset(&vc, 0, sizeof(vc)); - memset(&vd, 0, sizeof(vd)); - memset(&vb, 0, sizeof(vb)); - - vorbis_info_init(&vi); - float qv = bitrateToQuality(bitrateKbps); - if (vorbis_encode_init_vbr(&vi, nch, sampleRate, qv) != 0) - return; - - vorbis_comment_init(&vc); - vorbis_analysis_init(&vd, &vi); - vorbis_block_init(&vd, &vb); - ogg_stream_init(&os, serialNumber); - ok = true; - - // Emit the 3 Vorbis header packets immediately so callers can drain them. - ogg_packet hdr, hdr_comm, hdr_code; - vorbis_analysis_headerout(&vd, &vc, &hdr, &hdr_comm, &hdr_code); - ogg_stream_packetin(&os, &hdr); - ogg_stream_packetin(&os, &hdr_comm); - ogg_stream_packetin(&os, &hdr_code); - - ogg_page og; - while (ogg_stream_flush(&os, &og)) { - outBuf.insert(outBuf.end(), og.header, og.header + og.header_len); - outBuf.insert(outBuf.end(), og.body, og.body + og.body_len); - } - } - - ~Impl() { - if (ok) { - ogg_stream_clear(&os); - vorbis_block_clear(&vb); - vorbis_dsp_clear(&vd); - vorbis_comment_clear(&vc); - vorbis_info_clear(&vi); - } else { - vorbis_info_clear(&vi); - } - } - - void compact() { - if (readOffset > outBuf.size() / 2 && readOffset > 0) { - outBuf.erase(outBuf.begin(), - outBuf.begin() + static_cast(readOffset)); - readOffset = 0; - } - } - - void encode(const float *interleaved, int numFrames) { - if (!ok) - return; - - if (!interleaved || numFrames == 0) { - vorbis_analysis_wrote(&vd, 0); - } else { - float **buf = vorbis_analysis_buffer(&vd, numFrames); - for (int i = 0; i < numFrames; ++i) - for (int c = 0; c < nch; ++c) - buf[c][i] = interleaved[i * nch + c]; - vorbis_analysis_wrote(&vd, numFrames); - } - - ogg_packet op; - ogg_page og; - while (vorbis_analysis_blockout(&vd, &vb) == 1) { - vorbis_analysis(&vb, nullptr); - vorbis_bitrate_addblock(&vb); - while (vorbis_bitrate_flushpacket(&vd, &op)) { - ogg_stream_packetin(&os, &op); - while (ogg_stream_pageout(&os, &og)) { - outBuf.insert(outBuf.end(), og.header, og.header + og.header_len); - outBuf.insert(outBuf.end(), og.body, og.body + og.body_len); - } - } - } - compact(); - } -}; - -VorbisEncoder::VorbisEncoder(int sr, int nch, int brkbps, int serno) - : p(std::make_unique(sr, nch, brkbps, serno)) {} -VorbisEncoder::~VorbisEncoder() = default; - -void VorbisEncoder::encode(const float *interleaved, int numFrames) { - p->encode(interleaved, numFrames); -} - -int VorbisEncoder::available() const { - return static_cast(p->outBuf.size() - p->readOffset); -} - -const void *VorbisEncoder::data() const { - return p->outBuf.data() + p->readOffset; -} - -void VorbisEncoder::advance(int count) { - p->readOffset = - std::min(p->readOffset + static_cast(count), p->outBuf.size()); - p->compact(); -} diff --git a/src/VorbisCodec.h b/src/VorbisCodec.h index c2df386..37bb1b1 100644 --- a/src/VorbisCodec.h +++ b/src/VorbisCodec.h @@ -1,43 +1,8 @@ #pragma once -#include -class VorbisDecoder { -public: - VorbisDecoder(); - ~VorbisDecoder(); - VorbisDecoder(const VorbisDecoder &) = delete; - VorbisDecoder &operator=(const VorbisDecoder &) = delete; +// Adopted from chalkwalk-ninjam (libs/ninjam, MIT). - void decode(const void *data, int len); +#include - int available() const; - const float *pcm() const; - void skip(int count); - - int sampleRate() const; - int numChannels() const; - -private: - struct Impl; - std::unique_ptr p; -}; - -class VorbisEncoder { -public: - VorbisEncoder(int sampleRate, int numChannels, int bitrateKbps, - int serialNumber); - ~VorbisEncoder(); - VorbisEncoder(const VorbisEncoder &) = delete; - VorbisEncoder &operator=(const VorbisEncoder &) = delete; - - // Pass nullptr/0 to flush end-of-stream. - void encode(const float *interleaved, int numFrames); - - int available() const; - const void *data() const; - void advance(int count); - -private: - struct Impl; - std::unique_ptr p; -}; +using chalkwalk::ninjam::VorbisDecoder; +using chalkwalk::ninjam::VorbisEncoder; diff --git a/test/AuditMain.cpp b/test/AuditMain.cpp index 314018e..37b9e8b 100644 --- a/test/AuditMain.cpp +++ b/test/AuditMain.cpp @@ -194,21 +194,6 @@ int main() { results.push_back(auditState("audio device trouble view", {&trouble})); } - // A state that examines exactly what the previous one did has not been - // reached, and its clean verdict means nothing. Fail loudly rather than - // bank it. - for (size_t i = 1; i < results.size(); ++i) { - if (results[i].coverage.nodes == results[i - 1].coverage.nodes && - results[i].coverage.roots == results[i - 1].coverage.roots) { - std::fprintf(stderr, - "audit: state '%s' examined the same %d component(s) as " - "'%s' -- it was never reached\n", - results[i].name.toRawUTF8(), results[i].coverage.nodes, - results[i - 1].name.toRawUTF8()); - return 1; - } - } - // Remote player strips do not exist until somebody joins, so the whole // remote half of the surface -- the per-channel faders, mutes, solos, Recv // buttons and bus dropdowns -- had never been audited at all. The loopback @@ -235,6 +220,17 @@ int main() { results.push_back( auditState("two remote players, three channels", {editor})); + // A chart announced in chat grows the header by a row and puts the key + // suggestion on the chip, so it is a surface of its own -- and an + // unaudited state is how the connect dialog went unchecked for its whole + // life. The chart goes in as an ordinary chat message because that is how + // one really arrives. + server.sendChat("MSG", "guitarist", "| Dm7 | G7 | Cmaj7 |"); + pump(400); + settle(*editor); + + results.push_back(auditState("a chart announced in chat", {editor})); + // Traced line by line: the audit has hung somewhere in this teardown on CI // and every call in it is meant to be bounded, so the next hang needs to // name the one that is not. See ROADMAP.md. @@ -257,6 +253,29 @@ int main() { std::fprintf(stderr, "audit: teardown complete, reporting\n"); std::fflush(stderr); + // A state that examines exactly what the previous one did has not been + // reached, and its clean verdict means nothing. Fail loudly rather than bank + // it. + // + // Keyboard reach is part of the comparison, not just the component count: a + // state whose difference is which controls are OFFERED rather than which + // exist -- a chip appearing, say -- has the same tree with two more stops in + // it. Counting only nodes would call that state unreached when it is the + // whole point of it. + for (size_t i = 1; i < results.size(); ++i) { + const auto &now = results[i].coverage; + const auto &before = results[i - 1].coverage; + if (now.nodes == before.nodes && now.roots == before.roots && + now.focusable == before.focusable) { + std::fprintf(stderr, + "audit: state '%s' examined the same %d component(s) as " + "'%s' -- it was never reached\n", + results[i].name.toRawUTF8(), now.nodes, + results[i - 1].name.toRawUTF8()); + return 1; + } + } + int total = 0; for (const auto &r : results) { std::printf("=== %s ===\n%s\n", r.name.toRawUTF8(), r.report.toRawUTF8()); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 7e955cc..3045790 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -18,20 +18,16 @@ juce_generate_juce_header(NinjamTests) target_sources(NinjamTests PRIVATE TestMain.cpp - Sha1Tests.cpp - VorbisCodecTests.cpp - NinjamProtocolTests.cpp - IntervalClockTests.cpp MetronomeVoiceTests.cpp GainUtilsTests.cpp SyncStateTests.cpp AudioDeviceStartupTests.cpp ShortcutsTests.cpp - ChannelMixTests.cpp AccessibilityAuditTests.cpp - SpscRingTests.cpp ChatFormatTests.cpp - MusicalKeyTests.cpp + KeyTagTests.cpp + SharedContractTests.cpp + LeadLineTests.cpp ClipsortLogTests.cpp StemRenderTests.cpp RunGateTests.cpp @@ -42,19 +38,18 @@ target_sources(NinjamTests TransmitSpansTests.cpp FakeNinjamServer.cpp LoopbackTests.cpp + PracticeServerTests.cpp + PracticeRoomTests.cpp AudioLoopbackTests.cpp RealServerTests.cpp ReferenceFixtureTests.cpp ${CMAKE_SOURCE_DIR}/src/NinjamClient.cpp - ${CMAKE_SOURCE_DIR}/src/IntervalClock.cpp ${CMAKE_SOURCE_DIR}/src/MetronomeVoice.cpp - ${CMAKE_SOURCE_DIR}/src/Sha1.cpp - ${CMAKE_SOURCE_DIR}/src/VorbisCodec.cpp - ${CMAKE_SOURCE_DIR}/src/NinjamProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/PracticeServer.cpp + ${CMAKE_SOURCE_DIR}/src/PracticeRoom.cpp ${CMAKE_SOURCE_DIR}/src/ChatFormat.cpp ${CMAKE_SOURCE_DIR}/src/ClipsortLog.cpp ${CMAKE_SOURCE_DIR}/src/SessionWriter.cpp - ${CMAKE_SOURCE_DIR}/src/MusicalKey.cpp ${CMAKE_SOURCE_DIR}/src/AccessibilityAudit.cpp ) @@ -72,6 +67,11 @@ target_compile_definitions(NinjamTests # would break headless runs. target_link_libraries(NinjamTests PRIVATE + chalkwalk::music + chalkwalk::dsp + chalkwalk::jambot + chalkwalk::dsp::measure + chalkwalk::ninjam juce::juce_audio_formats juce::juce_events ogg @@ -83,7 +83,19 @@ target_link_libraries(NinjamTests target_include_directories(NinjamTests PRIVATE ${CMAKE_SOURCE_DIR}/src) add_test(NAME ninjam-unit-tests COMMAND NinjamTests) -set_tests_properties(ninjam-unit-tests PROPERTIES TIMEOUT 120) +# Raised from 120, which the suite outgrew rather than regressed into. +# +# Roughly: BotBand 57 s, Loopback 22 s, PracticeRoom 17 s, BotDsp 17 s, +# AudioLoopback 13 s. Most of that is rendering audio and measuring it, which is +# the work these tests exist to do -- the band is now the largest part of the +# codebase and it is tested by listening to it with instruments rather than by +# checking flags. +# +# A timeout is a backstop against a hang, not a performance budget, so it is set +# well clear of the real figure. The real figure is still worth watching: two +# minutes is a long time to wait between edits, and `ROADMAP.md` tracks +# shortening it. +set_tests_properties(ninjam-unit-tests PROPERTIES TIMEOUT 300) # Source-level guard; see the script for why this one is worth a test of its # own. The bug it catches is invisible in the standalone, which is where we do @@ -93,6 +105,17 @@ add_test(NAME no-build-standalone-macro -DSRC_DIR=${CMAKE_SOURCE_DIR}/src -P ${CMAKE_SOURCE_DIR}/cmake/CheckNoStandaloneMacro.cmake) +# The same shape, guarding the boundary the music layer is being moved across. +# A `juce::String` added in passing still builds and still passes; it is only +# discovered when somebody tries to move the file. +# The bots are being extracted; this fails when the set of things that would +# have to come with them changes. See the script. + +add_test(NAME music-layer-is-juce-free + COMMAND ${CMAKE_COMMAND} + -DSRC_DIR=${CMAKE_SOURCE_DIR}/src + -P ${CMAKE_SOURCE_DIR}/cmake/CheckMusicLayerIsJuceFree.cmake) + # --------------------------------------------------------------------------- # Accessibility audit over the real component tree. # @@ -126,6 +149,7 @@ target_compile_definitions(AntiphonAudit JUCE_MODAL_LOOPS_PERMITTED=1) target_link_libraries(AntiphonAudit + PRIVATE chalkwalk::music chalkwalk::dsp chalkwalk::ninjam PRIVATE Antiphon antiphon_fonts diff --git a/test/ChannelMixTests.cpp b/test/ChannelMixTests.cpp deleted file mode 100644 index a1482b3..0000000 --- a/test/ChannelMixTests.cpp +++ /dev/null @@ -1,149 +0,0 @@ -#include - -#include "ChannelMix.h" - -namespace { - -class ChannelMixTests : public juce::UnitTest { -public: - ChannelMixTests() : juce::UnitTest("ChannelMix", "ChannelMix") {} - - void runTest() override { - // Distinguishable constants, so "took the left channel" and "summed both" - // cannot produce the same number. - const float L = 0.8f, R = 0.2f; - const float expectedSum = 0.5f * (L + R); // 0.5 - std::vector left(64, L), right(64, R); - - beginTest("mono sums both sides rather than discarding one"); - { - const auto f = - ChannelMix::sourceFrame(left.data(), right.data(), true, 0); - expectWithinAbsoluteError(f.left, expectedSum, 1.0e-6f); - expectWithinAbsoluteError(f.right, expectedSum, 1.0e-6f); - // The bug this replaced: mono selected the left channel, throwing the - // right half of a stereo source away. If that ever comes back, the two - // assertions above still pass for a source where L == R, so pin it - // against a source where they differ. - expect(std::abs(f.left - L) > 0.01f, - "mono must not simply be the left channel"); - expect(std::abs(f.right - R) > 0.01f, - "mono must not simply be the right channel"); - } - - beginTest("stereo passes both sides through untouched"); - { - const auto f = - ChannelMix::sourceFrame(left.data(), right.data(), false, 0); - expectWithinAbsoluteError(f.left, L, 1.0e-6f); - expectWithinAbsoluteError(f.right, R, 1.0e-6f); - } - - beginTest("a mono bus feeds both sides from its single channel"); - { - // srcR is null when the assigned input bus has one channel. The mono flag - // must not change the result: there is nothing to sum with. - for (bool mono : {false, true}) { - const auto f = ChannelMix::sourceFrame(left.data(), nullptr, mono, 0); - expectWithinAbsoluteError(f.left, L, 1.0e-6f); - expectWithinAbsoluteError(f.right, L, 1.0e-6f); - } - } - - beginTest("a missing source is silence, not a read of null"); - { - const auto f = ChannelMix::sourceFrame(nullptr, nullptr, false, 0); - expectEquals(f.left, 0.0f); - expectEquals(f.right, 0.0f); - } - - beginTest("summing a correlated source holds its level"); - { - // Averaging rather than adding: an identical signal on both sides must - // come out at its original amplitude, not doubled into clipping. - std::vector same(16, 0.9f); - const auto f = ChannelMix::sourceFrame(same.data(), same.data(), true, 0); - expectWithinAbsoluteError(f.left, 0.9f, 1.0e-6f); - } - - beginTest("peaks are measured after mono summing"); - { - // The meter bug: peaks were taken from the raw input and ignored mono, so - // a mono channel displayed an independent stereo pair while transmitting - // a single summed signal. - const auto p = ChannelMix::peaks(left.data(), right.data(), true, 0, 64, - {1.0f, 1.0f}); - expectWithinAbsoluteError(p.left, expectedSum, 1.0e-6f); - expectWithinAbsoluteError(p.right, expectedSum, 1.0e-6f); - expect(std::abs(p.right - R) > 0.01f, - "a mono channel must not meter the raw right input"); - - const auto ps = ChannelMix::peaks(left.data(), right.data(), false, 0, 64, - {1.0f, 1.0f}); - expectWithinAbsoluteError(ps.left, L, 1.0e-6f); - expectWithinAbsoluteError(ps.right, R, 1.0e-6f); - } - - beginTest("peaks find the loudest frame and honour gain"); - { - std::vector ramp(32, 0.1f); - ramp[17] = -0.75f; // negative, to prove the peak is on magnitude - const auto p = - ChannelMix::peaks(ramp.data(), nullptr, false, 0, 32, {0.5f, 0.5f}); - expectWithinAbsoluteError(p.left, 0.375f, 1.0e-6f); - } - - beginTest("pan gains hold the centre and reach full on one side"); - { - const auto c = ChannelMix::panGains(1.0f, 0.0f); - expectWithinAbsoluteError(c.left, 1.0f, 1.0e-6f); - expectWithinAbsoluteError(c.right, 1.0f, 1.0e-6f); - const auto hardLeft = ChannelMix::panGains(1.0f, -1.0f); - expectWithinAbsoluteError(hardLeft.left, 1.0f, 1.0e-6f); - expectWithinAbsoluteError(hardLeft.right, 0.0f, 1.0e-6f); - const auto hardRight = ChannelMix::panGains(1.0f, 1.0f); - expectWithinAbsoluteError(hardRight.left, 0.0f, 1.0e-6f); - expectWithinAbsoluteError(hardRight.right, 1.0f, 1.0e-6f); - } - - beginTest("write fills a destination segment with gained frames"); - { - std::vector dl(8, -1.0f), dr(8, -1.0f); - ChannelMix::write(dl.data(), dr.data(), left.data(), right.data(), true, - 4, 8, {2.0f, 0.5f}); - for (int i = 0; i < 8; ++i) { - expectWithinAbsoluteError(dl[(size_t)i], expectedSum * 2.0f, 1.0e-6f); - expectWithinAbsoluteError(dr[(size_t)i], expectedSum * 0.5f, 1.0e-6f); - } - } - - beginTest("write reads from the requested source offset"); - { - // The transmit ring is written in up to two segments, so the source - // offset has to be honoured or the second segment repeats the first. - std::vector ramp(16); - for (int i = 0; i < 16; ++i) - ramp[(size_t)i] = (float)i; - std::vector dst(4, 0.0f); - ChannelMix::write(dst.data(), nullptr, ramp.data(), nullptr, false, 12, 4, - {1.0f, 1.0f}); - expectWithinAbsoluteError(dst[0], 12.0f, 1.0e-6f); - expectWithinAbsoluteError(dst[3], 15.0f, 1.0e-6f); - } - - beginTest("addInto accumulates rather than overwriting"); - { - std::vector dl(4, 0.25f), dr(4, 0.25f); - ChannelMix::addInto(dl.data(), dr.data(), left.data(), right.data(), - false, 4, {1.0f, 1.0f}); - for (int i = 0; i < 4; ++i) { - expectWithinAbsoluteError(dl[(size_t)i], 0.25f + L, 1.0e-6f); - expectWithinAbsoluteError(dr[(size_t)i], 0.25f + R, 1.0e-6f); - } - } - } -}; - -static ChannelMixTests channelMixTests; - -} // namespace diff --git a/test/ChatFormatTests.cpp b/test/ChatFormatTests.cpp index 8b748fe..304bba9 100644 --- a/test/ChatFormatTests.cpp +++ b/test/ChatFormatTests.cpp @@ -183,6 +183,33 @@ class ChatFormatTests : public juce::UnitTest { expect(l.category == Category::Voting); expect(l.text.startsWith("~~")); } + + beginTest("what the server accepts is two ranges, not one"); + { + // The vote path and the admin path disagree, in both the reference + // server and libninjam, and the difference is not cosmetic: a BPI of 124 + // is settable by an admin and unvotable by anyone, and a BPM of 30 is + // settable by an admin and unvotable by anyone. Measured against a real + // server and then read out of the source; see docs/PROTOCOL.md. + expect(ChatFormat::isVotableBpm(40) && ChatFormat::isVotableBpm(400)); + expect(!ChatFormat::isVotableBpm(39) && !ChatFormat::isVotableBpm(401)); + expect(ChatFormat::isVotableBpi(2) && ChatFormat::isVotableBpi(64)); + expect(!ChatFormat::isVotableBpi(1) && !ChatFormat::isVotableBpi(65)); + + expect(ChatFormat::isAdminSettableBpm(20), "admin BPM goes lower"); + expect(ChatFormat::isAdminSettableBpi(1024), "admin BPI goes far higher"); + expect(!ChatFormat::isAdminSettableBpm(19)); + expect(!ChatFormat::isAdminSettableBpi(1025)); + + // The two cases that motivated this, and the reason it is not one range: + // both are legal on the server and neither can be voted for. + expect(ChatFormat::isAdminSettableBpi(124) && + !ChatFormat::isVotableBpi(124), + "BPI 124: settable, not votable"); + expect(ChatFormat::isAdminSettableBpm(30) && + !ChatFormat::isVotableBpm(30), + "BPM 30: settable, not votable -- and an ordinary DAW tempo"); + } } }; diff --git a/test/FakeNinjamServer.cpp b/test/FakeNinjamServer.cpp index a3dbe6d..9a97679 100644 --- a/test/FakeNinjamServer.cpp +++ b/test/FakeNinjamServer.cpp @@ -158,10 +158,10 @@ void FakeNinjamServer::run() { if (!NinjamProtocol::readFrameHeader(header, frame)) break; - juce::MemoryBlock payload; + ByteBuffer payload; if (frame.length > 0) { - payload.setSize(frame.length, true); - if (!readExactly(payload.getData(), (int)frame.length)) + payload.resize(frame.length); + if (!readExactly(payload.data(), (int)frame.length)) break; } @@ -174,14 +174,14 @@ void FakeNinjamServer::run() { } void FakeNinjamServer::handleClientMessage(juce::uint8 type, - const juce::MemoryBlock &payload) { + const ByteBuffer &payload) { if (type == 0x80) { // CLIENT_AUTH_USER -> grant or deny. The reply must carry the channel cap: // the reference client stores it as m_max_localch and silently refuses to // transmit on any channel index at or above it, so a reply of just the // flag byte gets no audio at all from a stock client. auto reply = NinjamProtocol::buildAuthReply(grantAccess.load(), {}, 32); - send(0x01, reply.getData(), (int)reply.getSize()); + send(0x01, reply.data(), (int)reply.size()); if (!grantAccess.load()) return; @@ -210,14 +210,14 @@ void FakeNinjamServer::handleClientMessage(juce::uint8 type, } auto echoed = NinjamProtocol::buildIntervalBegin( begin.guid, begin.estimatedSize, begin.fourcc, begin.channelIndex, - user); - send(0x04, echoed.getData(), (int)echoed.getSize()); + user.toStdString()); + send(0x04, echoed.data(), (int)echoed.size()); return; } if (type == 0x84) { // UPLOAD_INTERVAL_WRITE and DOWNLOAD_INTERVAL_WRITE payloads are identical. - send(0x05, payload.getData(), (int)payload.getSize()); + send(0x05, payload.data(), (int)payload.size()); NinjamProtocol::IntervalWrite w; if (NinjamProtocol::parseIntervalWrite(payload, w) && w.isFinal) uploadsCompleted.fetch_add(1); @@ -256,8 +256,9 @@ void FakeNinjamServer::sendUserInfo(const juce::String &user, int chIdx, void FakeNinjamServer::sendChat(const juce::String &type, const juce::String &p1, const juce::String &p2) { - auto b = NinjamProtocol::buildChat(type, p1, p2); - send(0xC0, b.getData(), (int)b.getSize()); + auto b = NinjamProtocol::buildChat(type.toStdString(), p1.toStdString(), + p2.toStdString()); + send(0xC0, b.data(), (int)b.size()); } int FakeNinjamServer::countReceived(juce::uint8 type) const { @@ -279,7 +280,7 @@ FakeNinjamServer::messagesOfType(juce::uint8 type) const { return out; } -juce::MemoryBlock FakeNinjamServer::lastPayloadOfType(juce::uint8 type) const { +ByteBuffer FakeNinjamServer::lastPayloadOfType(juce::uint8 type) const { juce::ScopedLock sl(stateMutex); for (int i = received.size() - 1; i >= 0; --i) if (received.getReference(i).type == type) diff --git a/test/FakeNinjamServer.h b/test/FakeNinjamServer.h index c5f0a9c..05c37a2 100644 --- a/test/FakeNinjamServer.h +++ b/test/FakeNinjamServer.h @@ -1,5 +1,7 @@ #pragma once +#include + #include #include "NinjamProtocol.h" @@ -42,13 +44,13 @@ class FakeNinjamServer : private juce::Thread { // Observations, all guarded internally. struct Received { juce::uint8 type; - juce::MemoryBlock payload; + chalkwalk::ninjam::ByteBuffer payload; }; bool hasClient() const; int countReceived(juce::uint8 type) const; juce::Array messagesOfType(juce::uint8 type) const; - juce::MemoryBlock lastPayloadOfType(juce::uint8 type) const; + chalkwalk::ninjam::ByteBuffer lastPayloadOfType(juce::uint8 type) const; int completedUploads() const { return uploadsCompleted.load(); } void clearReceived(); @@ -56,7 +58,8 @@ class FakeNinjamServer : private juce::Thread { private: void run() override; - void handleClientMessage(juce::uint8 type, const juce::MemoryBlock &payload); + void handleClientMessage(juce::uint8 type, + const chalkwalk::ninjam::ByteBuffer &payload); bool send(juce::uint8 type, const void *data, int size); bool readExactly(void *dest, int numBytes); diff --git a/test/IntervalClockTests.cpp b/test/IntervalClockTests.cpp deleted file mode 100644 index c47c68d..0000000 --- a/test/IntervalClockTests.cpp +++ /dev/null @@ -1,408 +0,0 @@ -#include - -#include "IntervalClock.h" - -#include -#include - -namespace { - -// Verbatim reproduction of the float phase accumulator this class replaced -// (PluginProcessor.cpp interval loop, stripped of metronome and flash state). -// Used only to show that the new clock lands on the same boundaries and beats -// over the first interval. It is expected to diverge over many intervals: the -// old accumulator tracked the exact fractional interval length while the new -// one, like the reference client, uses a fixed truncated integer length. -struct LegacyPhaseReference { - double phaseBeats = 0.0; - int lastTimestampedBeat = -1; - - struct Hit { - int sample; - int beat; - bool isBoundary; - }; - - std::vector run(int bpm, int bpi, double sampleRate, int numSamples) { - std::vector hits; - const double beatsPerSample = (bpm / 60.0) / sampleRate; - for (int n = 0; n < numSamples; ++n) { - phaseBeats += beatsPerSample; - if (phaseBeats >= bpi) - phaseBeats -= bpi; - - const double fractionalBeat = phaseBeats - std::floor(phaseBeats); - - if (phaseBeats < beatsPerSample) - hits.push_back({n, 0, true}); - - if (fractionalBeat < 0.05) { - const int currentBeat = (int)std::floor(phaseBeats); - if (currentBeat != lastTimestampedBeat) { - lastTimestampedBeat = currentBeat; - hits.push_back({n, currentBeat, false}); - } - } - } - return hits; - } -}; - -// Runs the clock over `total` samples in fixed-size blocks, returning events -// with absolute sample positions. -struct Run { - std::vector intervalStarts; - std::vector> beats; // (absolute sample, beat index) -}; - -Run runClock(IntervalClock &clock, int totalSamples, int blockSize) { - Run r; - std::vector events; - int pos = 0; - while (pos < totalSamples) { - const int n = std::min(blockSize, totalSamples - pos); - events.clear(); - clock.advance(n, events); - for (const auto &e : events) { - if (e.type == IntervalClock::Event::Type::IntervalStart) - r.intervalStarts.push_back(pos + e.sampleOffset); - else - r.beats.emplace_back(pos + e.sampleOffset, e.beatIndex); - } - pos += n; - } - return r; -} - -class IntervalClockTests : public juce::UnitTest { -public: - IntervalClockTests() : juce::UnitTest("IntervalClock", "IntervalClock") {} - - void runTest() { - const std::vector bpms{40, 90, 120, 137, 200}; - const std::vector bpis{4, 8, 16, 24}; - const std::vector rates{44100.0, 48000.0, 88200.0, 96000.0}; - const std::vector blocks{1, 32, 64, 441, 512, 1024}; - - beginTest("interval length matches the reference client formula"); - for (int bpm : bpms) - for (int bpi : bpis) - for (double sr : rates) { - IntervalClock c; - c.prepare(sr); - c.setTempo(bpm, bpi); - const int expected = - (int)((double)bpi / ((double)bpm * (1.0 / 60.0)) * sr); - expectEquals(c.samplesPerInterval(), expected, - juce::String(bpm) + "bpm/" + juce::String(bpi) + "bpi/" + - juce::String(sr)); - } - - beginTest("every interval is exactly the same length"); - // The defect this class was written to remove: the float accumulator made - // the boundary walk by a sample from interval to interval, which changed - // the length of every transmitted interval. - for (int bpm : bpms) - for (int bpi : bpis) - for (double sr : rates) { - IntervalClock c; - c.prepare(sr); - c.setTempo(bpm, bpi); - const int len = c.samplesPerInterval(); - auto r = runClock(c, len * 200 + 1, 512); - expect(r.intervalStarts.size() >= 200, "too few intervals"); - bool uniform = true; - for (size_t i = 1; i < r.intervalStarts.size(); ++i) - if (r.intervalStarts[i] - r.intervalStarts[i - 1] != len) - uniform = false; - expect(uniform, "interval length drifted at " + juce::String(bpm) + - "bpm/" + juce::String(bpi) + "bpi/" + - juce::String(sr)); - } - - beginTest("event positions are independent of block size"); - for (int bpm : bpms) - for (int bpi : bpis) - for (double sr : rates) { - Run reference; - bool first = true; - for (int block : blocks) { - IntervalClock c; - c.prepare(sr); - c.setTempo(bpm, bpi); - const int total = c.samplesPerInterval() * 3 + 7; - auto r = runClock(c, total, block); - if (first) { - reference = r; - first = false; - } else { - expect(r.intervalStarts == reference.intervalStarts, - "boundaries moved at block " + juce::String(block)); - expect(r.beats == reference.beats, - "beats moved at block " + juce::String(block)); - } - } - // One single giant block must agree too. - IntervalClock c; - c.prepare(sr); - c.setTempo(bpm, bpi); - const int total = c.samplesPerInterval() * 3 + 7; - auto giant = runClock(c, total, total); - expect(giant.intervalStarts == reference.intervalStarts, - "boundaries moved in a single block"); - expect(giant.beats == reference.beats, - "beats moved in a single block"); - } - - beginTest("exactly bpi beats and one boundary per interval"); - for (int bpm : bpms) - for (int bpi : bpis) - for (double sr : rates) { - IntervalClock c; - c.prepare(sr); - c.setTempo(bpm, bpi); - auto r = runClock(c, c.samplesPerInterval() * 5, 256); - expectEquals((int)r.intervalStarts.size(), 5); - expectEquals((int)r.beats.size(), 5 * bpi); - for (size_t i = 0; i < r.beats.size(); ++i) - expectEquals(r.beats[i].second, (int)(i % (size_t)bpi)); - } - - beginTest("beat 0 coincides with the interval boundary"); - { - IntervalClock c; - c.prepare(48000.0); - c.setTempo(120, 8); - auto r = runClock(c, c.samplesPerInterval() * 3, 128); - for (size_t i = 0; i < r.intervalStarts.size(); ++i) { - const auto &beat0 = r.beats[i * 8]; - expectEquals(beat0.second, 0); - expectEquals(beat0.first, r.intervalStarts[i]); - } - } - - beginTest("no drift over an hour of audio"); - { - IntervalClock c; - c.prepare(48000.0); - c.setTempo(120, 8); - const int len = c.samplesPerInterval(); - const int total = 48000 * 3600; - auto r = runClock(c, total, 1024); - expectEquals((int)r.intervalStarts.size(), (total + len - 1) / len); - expectEquals(r.intervalStarts.back(), - ((int)r.intervalStarts.size() - 1) * len); - } - - beginTest("agrees with the legacy float clock over the first interval"); - for (int bpm : bpms) - for (int bpi : bpis) - for (double sr : rates) { - IntervalClock c; - c.prepare(sr); - c.setTempo(bpm, bpi); - const int len = c.samplesPerInterval(); - - LegacyPhaseReference legacy; - auto legacyHits = legacy.run(bpm, bpi, sr, len); - auto r = runClock(c, len, 64); - - // The legacy clock emitted beat 0 of the first interval a sample or - // two in, where the new clock emits it at sample 0; compare the - // remaining beats, which is what the metronome and the UI key off. - for (const auto &h : legacyHits) { - if (h.isBoundary || h.beat == 0) - continue; - bool matched = false; - for (const auto &b : r.beats) - if (b.second == h.beat && std::abs(b.first - h.sample) <= 2) - matched = true; - expect(matched, "legacy beat " + juce::String(h.beat) + " at " + - juce::String(h.sample) + " unmatched (" + - juce::String(bpm) + "bpm/" + juce::String(bpi) + - "bpi/" + juce::String(sr) + ")"); - } - } - - beginTest("block splitting reconstructs the block exactly"); - { - // Every sample of the block must land in exactly one segment, in order. - IntervalClock c; - c.prepare(48000.0); - c.setTempo(137, 16); - std::vector ev; - std::vector segs; - - for (int block : {1, 32, 64, 441, 512, 1024}) { - c.reset(); - for (int i = 0; i < 4000; ++i) { - ev.clear(); - c.advance(block, ev); - IntervalClock::splitAtIntervalStarts(ev, block, segs); - - int covered = 0; - for (size_t s = 0; s < segs.size(); ++s) { - expectEquals(segs[s].start, covered, "segments must be contiguous"); - expect(segs[s].count >= 0); - covered += segs[s].count; - } - expectEquals(covered, block, "segments must cover the whole block"); - } - } - } - - beginTest("transmitted intervals are exactly one interval long"); - { - // The point of the split. Accumulating whole blocks and flushing at the - // boundary rounds each transmitted interval up to a block multiple -- - // measured as roughly +1.3 ms of stretch at every seam against the real - // reference client (work item #27). Splitting makes it exact for any - // block size, including ones that do not divide the interval. - const std::vector> tempos{ - {137, 16}, {120, 8}, {90, 16}}; - for (const auto &[bpm, bpi] : tempos) - for (double sr : {44100.0, 48000.0}) - for (int block : {64, 127, 512, 1024}) { - IntervalClock c; - c.prepare(sr); - c.setTempo(bpm, bpi); - const int len = c.samplesPerInterval(); - - std::vector ev; - std::vector segs; - int pending = 0; - std::vector transmitted; - - const int totalBlocks = (len * 6) / block; - for (int i = 0; i < totalBlocks; ++i) { - ev.clear(); - c.advance(block, ev); - IntervalClock::splitAtIntervalStarts(ev, block, segs); - for (const auto &s : segs) { - pending += s.count; - if (s.closesInterval) { - transmitted.push_back(pending); - pending = 0; - } - } - } - - // The first entry is a partial interval (the clock starts at a - // boundary), so ignore it and require the rest to be exact. - expect(transmitted.size() >= 3, - "too few intervals at block " + juce::String(block)); - for (size_t i = 1; i < transmitted.size(); ++i) - expectEquals(transmitted[i], len, - "interval " + juce::String((int)i) + " at " + - juce::String(bpm) + "bpm/" + juce::String(bpi) + - "bpi/" + juce::String(sr, 0) + "Hz block " + - juce::String(block)); - } - } - - beginTest("reset returns to the top of an interval"); - { - IntervalClock c; - c.prepare(48000.0); - c.setTempo(120, 8); - std::vector ev; - c.advance(10000, ev); - expect(c.samplePosInInterval() > 0); - - c.reset(); - expectEquals((int)c.samplePosInInterval(), 0); - expect(c.phaseBeats() == 0.0); - - ev.clear(); - c.advance(64, ev); - expect(!ev.empty()); - expect(ev[0].type == IntervalClock::Event::Type::IntervalStart); - expectEquals(ev[0].sampleOffset, 0); - } - - beginTest("tempo change takes effect at the next boundary"); - { - IntervalClock c; - c.prepare(48000.0); - c.setTempo(120, 8); - const int oldLen = c.samplesPerInterval(); - - std::vector ev; - c.advance(oldLen / 2, ev); // mid-interval - c.setTempo(90, 12); - expectEquals(c.samplesPerInterval(), oldLen, - "current interval must keep its original length"); - - ev.clear(); - c.advance(oldLen, ev); - expectEquals(c.getBpm(), 90); - expectEquals(c.getBpi(), 12); - - // No event may ever carry a beat index outside the new range. - IntervalClock d; - d.prepare(48000.0); - d.setTempo(120, 24); - ev.clear(); - d.advance(d.samplesPerInterval() / 2, ev); - d.setTempo(120, 4); // large drop in bpi - ev.clear(); - d.advance(d.samplesPerInterval() * 4, ev); - - // The in-flight interval correctly finishes at the old tempo, so only - // check events from the first boundary after the change onwards. - bool afterSwitch = false; - int checked = 0; - for (const auto &e : ev) { - if (e.type == IntervalClock::Event::Type::IntervalStart) - afterSwitch = true; - if (!afterSwitch) - continue; - ++checked; - expect(e.beatIndex >= 0 && e.beatIndex < d.getBpi(), - "beat index " + juce::String(e.beatIndex) + " out of range"); - } - expect(checked > 0, "no events after the tempo switch"); - } - - beginTest("degenerate inputs produce no events and do not hang"); - { - std::vector ev; - - IntervalClock a; - a.prepare(0.0); - a.setTempo(120, 8); - a.advance(4096, ev); - expect(ev.empty()); - expect(!a.isValid()); - - IntervalClock b; - b.prepare(48000.0); - b.setTempo(0, 8); // ignored - b.setTempo(120, 0); - ev.clear(); - b.advance(4096, ev); - // The rejected tempos leave the defaults in place, which are valid. - expectEquals(b.getBpm(), 120); - - IntervalClock e; - e.prepare(48000.0); - e.setTempo(120, 8); - ev.clear(); - e.advance(0, ev); - e.advance(-5, ev); - expect(ev.empty()); - - // Absurd tempo must still terminate. - IntervalClock f; - f.prepare(8000.0); - f.setTempo(60000, 32); - ev.clear(); - f.advance(100000, ev); - expect(f.samplesPerInterval() > 0); - } - } -}; - -static IntervalClockTests intervalClockTests; - -} // namespace diff --git a/test/KeyTagTests.cpp b/test/KeyTagTests.cpp new file mode 100644 index 0000000..e2f2a51 --- /dev/null +++ b/test/KeyTagTests.cpp @@ -0,0 +1,114 @@ +#include "../src/MusicalKey.h" +#include +#include + +// The tag, and nothing else. +// +// Reading and writing a key -- "D minor", its spelling, its scale -- moved to +// `chalkwalk::music::Notation` and is tested there. What is left is the part +// that is Antiphon's: how a key travels over Ninjam chat, which is a protocol +// decision rather than a musical one. + +namespace { + +class KeyTagTests : public juce::UnitTest { +public: + KeyTagTests() : juce::UnitTest("KeyTag", "music") {} + + void runTest() override { + using namespace MusicalKey; + + beginTest("only the tagged form is picked up from a chat line"); + { + expect(!parseTagged("lets play in D minor").valid, + "free text must not set the key"); + expect(!parseTagged("key: D minor").valid, "the bracket is required"); + + const auto tagged = parseTagged("[key: D minor]"); + expect(tagged.valid); + expectEquals(displayName(tagged), std::string("D minor")); + } + + beginTest("a tag is found wherever it sits in the line"); + { + // It arrives inside a topic that may say other things too. + for (const auto *s : + {"[key: Dm]", "jam night -- [key: Dm] -- all welcome", + "trailing [key: Dm]", "[KEY: Dm]"}) + expectEquals(displayName(parseTagged(s)), std::string("D minor"), + std::string("failed on: ") + s); + } + + beginTest("a malformed tag yields no key rather than a wrong one"); + { + for (const auto *s : {"[key:", "[key: ]", "[key: bananas]", "[key Dm]", + "[key: Dm", "]key: Dm["}) + expect(!parseTagged(s).valid, + std::string("wrongly read as a key: ") + s); + } + + beginTest("what we send is what we parse"); + { + // The round trip that makes the convention work between two clients. + for (const auto *s : {"Dm", "F# Dorian", "Bb major", "C Locrian"}) { + const auto original = parseName(s); + expect(original.valid); + const auto message = buildTagged(original); + expect(chalkwalk::music::text::startsWith(message, "[key:")); + const auto received = parseTagged(message); + expect(received.valid, + "did not survive the round trip: " + juce::String(message)); + expect(received == original, + "changed in the round trip: " + juce::String(message)); + } + } + + beginTest("an invalid key builds and displays as nothing"); + { + Key none; + expect(buildTagged(none).empty()); + expect(displayName(none).empty()); + expect(scaleNotes(none).empty()); + } + + beginTest("a key announcement has two forms, and only one is sayable"); + { + // The tag, matched anywhere, so it can ride in the server topic. + expect(parseAnnouncement("[key: D minor]").valid); + expect(parseAnnouncement("blues jam [key: D minor] all welcome").valid); + expectEquals(displayName(parseAnnouncement("nice [key: G minor] one")), + std::string("G minor")); + + // The command form, line-leading only. + expectEquals(displayName(parseAnnouncement("/key G minor")), + std::string("G minor")); + expectEquals(displayName(parseAnnouncement(" /key Dm ")), + std::string("D minor")); + expect(parseAnnouncement("/KEY Am").valid, "case is not the point"); + + // ...and THAT is the whole reason the second form exists. A bot must be + // able to say how the key is set without setting it, which it can never + // do with the tag, because the tag is matched anywhere. + const auto advice = "the key is the room's. type \"" + + announcementAdvice(parseName("G minor")) + + "\" to change it."; + expect(!parseAnnouncement(advice).valid, + "a bot explaining the key would have set it: " + advice); + + // The same sentence built round the tag DOES set it -- kept as a test so + // nobody reintroduces the tag into reply text. + expect(parseAnnouncement("type \"[key: G minor]\" to change it").valid, + "the tag really is unsayable; this is why announcementAdvice " + "exists"); + + // Not a key announcement at all. + for (const char *no : {"what key are we in", "/keys are broken", + "i said /key earlier", "key: G minor"}) + expect(!parseAnnouncement(no).valid, juce::String(no) + " set the key"); + } + } +}; + +static KeyTagTests keyTagTests; + +} // namespace diff --git a/test/LeadLineTests.cpp b/test/LeadLineTests.cpp new file mode 100644 index 0000000..6f15973 --- /dev/null +++ b/test/LeadLineTests.cpp @@ -0,0 +1,391 @@ +#include +#include "../src/Harmony.h" +#include "../src/MusicalKey.h" +#include + +#include +#include + +#include + +// The lead's note choice, and the seam between antiphon's key model and +// chalkwalk-music's. +// +// Antiphon keeps `MusicalKey::Key` for harmony, chord spelling and roman +// numerals -- all of which genuinely need exactly seven degrees, and one of +// which (`spellNote`) refuses to run without them. The shared `KeySig` is a +// pitch-class mask of any size. The conversion runs one way only, at the point +// where a note is ranked. + +class LeadLineTests : public juce::UnitTest { +public: + LeadLineTests() : juce::UnitTest("LeadLine", "music") {} + + void runTest() override { + runKeyConversion(); + runChordConversion(); + runRhythmFromTheFigure(); + runWellFormed(); + runDeterminism(); + runChordAwareness(); + runMelodicShape(); + runIntervalSeam(); + runArticulation(); + } + +private: + static BotBand::Settings settingsFor(const juce::String &keyName, + std::uint32_t seed) { + auto key = MusicalKey::parseName(keyName.toStdString()); + return BotBand::defaults(key, 120, 8, 48000.0, seed); + } + + // Every mode antiphon can express must land on the right fifths window. + // Major and Minor are Ionian and Aeolian: they differ in what a player is + // shown, never in pitch, and a conversion that treated them as distinct + // would put the band in the wrong key for two of the nine spellings. + void runKeyConversion() { + beginTest("every mode converts to the right brightness"); + namespace m = chalkwalk::music; + const struct { + const char *name; + int brightness; + } cases[] = { + {"C major", m::kIonian}, {"C Ionian", m::kIonian}, + {"C minor", m::kAeolian}, {"C Aeolian", m::kAeolian}, + {"C Dorian", m::kDorian}, {"C Phrygian", m::kPhrygian}, + {"C Lydian", m::kLydian}, {"C Mixolydian", m::kMixolydian}, + {"C Locrian", m::kLocrian}, + }; + for (const auto &c : cases) { + const auto key = MusicalKey::parseName(c.name); + expect(key.valid, juce::String("parses ") + c.name); + const auto sig = BotBand::toKeySig(key); + expectEquals(static_cast(sig.brightness), c.brightness, + juce::String(c.name)); + expectEquals(static_cast(sig.root), 0); + } + + beginTest("the converted scale has the notes the mode has"); + for (const char *name : + {"D minor", "F# Dorian", "Bb Lydian", "E Phrygian"}) { + const auto key = MusicalKey::parseName(name); + const auto sig = BotBand::toKeySig(key); + const auto mask = m::pcMask(sig); + + const int *steps = MusicalKey::scaleSteps(key.mode); + for (int d = 0; d < MusicalKey::kScaleDegrees; ++d) { + const int pc = ((key.tonic + steps[d]) % 12 + 12) % 12; + expect(m::maskHas(mask, pc), + juce::String(name) + " contains degree " + juce::String(d)); + } + int count = 0; + for (int pc = 0; pc < 12; ++pc) + if (m::maskHas(mask, pc)) + ++count; + expectEquals(count, 7, juce::String(name) + " has seven notes"); + } + } + + void runChordConversion() { + beginTest("chord tones fold into pitch classes"); + Harmony::Chord c; + c.root = 2; // D + c.tones = {{0, 3, 7, 14, 0}}; // minor triad plus a ninth, unreduced + c.toneCount = 4; + + const auto sounding = BotBand::toSoundingChord(c); + expect(sounding.present()); + expectEquals(sounding.root, 2); + namespace m = chalkwalk::music; + expect(m::maskHas(sounding.tones, 2), "D"); + expect(m::maskHas(sounding.tones, 5), "F"); + expect(m::maskHas(sounding.tones, 9), "A"); + // The ninth is 14 semitones up and must fold to E, not be dropped. + expect(m::maskHas(sounding.tones, 4), "E, from the unreduced ninth"); + } + + // THE ONE-WAY COUPLING, asserted. + // + // Note choice may inform the rhythm; the rhythm must never depend on it. The + // onset grid is the Euclidean figure, which the rest of the band shares, so + // a note that sounded somewhere the figure has no onset would mean the lead + // had quietly stopped playing the same groove as everyone else. + void runRhythmFromTheFigure() { + beginTest("every sounding step is an onset of the lead's figure"); + for (const char *name : {"C major", "D minor", "Bb Lydian", "A Phrygian"}) + for (std::uint32_t seed = 1; seed <= 25; ++seed) { + const auto s = settingsFor(name, seed); + const auto f = BotBand::figureFor(BotBand::Voice::Lead, s); + for (int interval = 0; interval < 4; ++interval) { + const auto line = BotBand::leadLine(s, interval); + for (size_t i = 0; i < line.size(); ++i) + if (line[i] >= 0) + expect( + chalkwalk::music::hit((int)i, f.steps, f.pulses, f.rotation), + juce::String(name) + " seed " + juce::String((int)seed) + + ": note at step " + juce::String((int)i) + + ", which the figure does not strike"); + } + } + } + + void runWellFormed() { + beginTest("the line stays in range and in register"); + for (const char *name : {"C major", "F# Dorian", "Bb Lydian"}) + for (std::uint32_t seed = 1; seed <= 30; ++seed) { + const auto s = settingsFor(name, seed); + for (int interval = 0; interval < 4; ++interval) + for (int note : BotBand::leadLine(s, interval)) { + if (note < 0) + continue; + expect(note >= 0 && note <= 127, "a valid MIDI note"); + // The lead sits above the keys by design, and wandering out of + // that register is what makes it stop reading as a melody. + expect(note >= 48 && note <= 100, + juce::String(name) + ": note " + juce::String(note) + + " is outside the lead's register"); + } + } + } + + void runDeterminism() { + beginTest("the same seed gives the same line, every time"); + for (std::uint32_t seed = 1; seed <= 10; ++seed) { + const auto s = settingsFor("D minor", seed); + for (int interval = 0; interval < 3; ++interval) + expect(BotBand::leadLine(s, interval) == BotBand::leadLine(s, interval), + "reproducible from the seed"); + } + } + + // The line should follow the chart, measured as how often a sounding note is + // a tone of the chord under it. A floor rather than a target: pinning it too + // tightly would forbid the passing notes that make it a melody. + void runChordAwareness() { + beginTest("the line plays the changes"); + int hits = 0, notes = 0; + + for (const char *name : {"C major", "D minor", "Bb Lydian", "G Mixolydian"}) + for (std::uint32_t seed = 1; seed <= 40; ++seed) { + const auto s = settingsFor(name, seed); + const auto layout = Harmony::layoutChart(s.chart, s.bpi); + for (int interval = 0; interval < 4; ++interval) { + const auto line = BotBand::leadLine(s, interval); + for (size_t i = 0; i < line.size(); ++i) { + if (line[i] < 0) + continue; + ++notes; + const auto sounding = + BotBand::toSoundingChord(Harmony::chordAtStep(layout, (int)i)); + if (chalkwalk::music::maskHas(sounding.tones, line[i] % 12)) + ++hits; + } + } + } + + const double rate = notes ? (double)hits / notes : 0.0; + logMessage(" chord-tone rate: " + juce::String(rate, 3)); + expect(rate > 0.5, "over half the notes are chord tones"); + expect(rate < 0.95, + "not EVERY note is a chord tone -- that is an arpeggio"); + } + + // What the interval objective bought, asserted rather than described. + // + // The claim is not "the line moves by exactly this much", it is "wide + // awkward leaps do not happen, and the line is mostly stepwise". Every + // threshold below is set from what the previous behaviour actually measured, + // so each one is a real regression detector rather than a guess: + // + // before after threshold + // mean interval 2.17-2.84 2.17-2.34 < 2.6 + // stepwise 53%-67% 77%-79% > 70% + // repeated notes 20% 7.2%-9.5% < 15% + // ... of them static most 0%-0.3% < 1% + // awkward wide 20-31 per 1939 2-5 < 0.5% of moves + // + // The awkward-wide count is the sharpest: it was 31 in C major even AFTER + // the interval objective landed, because the melody's memory still reset at + // every interval boundary. Carrying the previous line's last note across + // that seam took it to zero. + void runMelodicShape() { + beginTest("the line moves mostly by step, and leaps idiomatically"); + for (const char *name : + {"C major", "D minor", "Bb Lydian", "G Mixolydian"}) { + int moves = 0, stepwise = 0, wideAwkward = 0, repeats = 0, + staticRepeats = 0; + long long motion = 0; + + for (std::uint32_t seed = 1; seed <= 40; ++seed) { + const auto s = settingsFor(name, seed); + const auto layout = Harmony::layoutChart(s.chart, s.bpi); + int last = -1; + chalkwalk::music::SoundingChord lastChord{}; + for (int interval = 0; interval < 6; ++interval) { + int step = -1; + for (int n : BotBand::leadLine(s, interval)) { + ++step; + if (n < 0) + continue; + const auto here = + BotBand::toSoundingChord(Harmony::chordAtStep(layout, step)); + const bool harmonyMoved = + here.root != lastChord.root || here.tones != lastChord.tones; + lastChord = here; + if (last >= 0) { + const int d = std::abs(n - last); + ++moves; + motion += d; + if (d == 0) { + ++repeats; + if (!harmonyMoved) + ++staticRepeats; + } + if (d <= 2) + ++stepwise; + // Wide AND not one of the leaps a melody actually makes. + if (d >= 8 && d != 12) + ++wideAwkward; + } + last = n; + } + } + } + + const double mean = moves ? (double)motion / moves : 0.0; + const double stepRate = moves ? (double)stepwise / moves : 0.0; + const double repeatRate = moves ? (double)repeats / moves : 0.0; + const double staticRate = moves ? (double)staticRepeats / moves : 0.0; + logMessage(juce::String(name) + ": mean " + juce::String(mean, 2) + + ", stepwise " + juce::String(100.0 * stepRate, 1) + + "%, repeats " + juce::String(100.0 * repeatRate, 1) + + "% (static " + juce::String(100.0 * staticRate, 2) + "%), " + + "%, wide awkward " + juce::String(wideAwkward)); + + expect(mean < 2.6, juce::String(name) + ": mean interval " + + juce::String(mean, 2) + " is too wide"); + expect(stepRate > 0.7, juce::String(name) + ": only " + + juce::String(100.0 * stepRate, 1) + + "% of moves are stepwise"); + expect(wideAwkward * 200 < moves, + juce::String(name) + ": " + juce::String(wideAwkward) + + " awkward wide leaps in " + juce::String(moves) + " moves"); + + // A line that mostly repeats itself is a drone, and nothing else here + // notices. This was NOT hypothetical: with the repeat unpriced, adding + // a direction weight took repeats to 54% of all moves, because the + // objective had made standing still the cheapest thing to do. + // + // But the assertion that matters is the SECOND one. Repeats are not all + // the same event: over a chord that changed, a repeat is a common tone + // and belongs in the line; under a static chord it is standing still. + // Charging for one and not the other is the whole design, so the static + // rate is the exact quantity -- it should be near zero, while the total + // sits near a musical 7%-10%. + expect(repeatRate < 0.15, juce::String(name) + ": " + + juce::String(100.0 * repeatRate, 1) + + "% of moves repeat the note"); + expect(staticRate < 0.01, + juce::String(name) + ": " + juce::String(100.0 * staticRate, 2) + + "% of moves repeat under an unchanged chord"); + } + } + // The seam between two intervals is a real melodic move and must be priced + // like one. It was not: the line's memory reset every four seconds, which + // made the boundary the single most leap-prone moment in the whole melody. + // Asserted separately from the shape test because it is invisible there -- + // 200 bad moves in 1939 barely shift a mean. + void runIntervalSeam() { + beginTest("the line joins across the interval boundary"); + int seams = 0, wide = 0; + long long motion = 0; + + for (const char *name : {"C major", "D minor", "Bb Lydian", "G Mixolydian"}) + for (std::uint32_t seed = 1; seed <= 40; ++seed) { + const auto s = settingsFor(name, seed); + for (int interval = 1; interval < 6; ++interval) { + const auto before = BotBand::leadLine(s, interval - 1); + const auto after = BotBand::leadLine(s, interval); + + int last = -1; + for (int n : before) + if (n >= 0) + last = n; + int first = -1; + for (int n : after) + if (n >= 0) { + first = n; + break; + } + if (last < 0 || first < 0) + continue; + + ++seams; + const int d = std::abs(first - last); + motion += d; + if (d >= 8 && d != 12) + ++wide; + } + } + + const double mean = seams ? (double)motion / seams : 0.0; + logMessage(" " + juce::String(seams) + " seams, mean " + + juce::String(mean, 2) + ", awkward wide " + juce::String(wide)); + expect(seams > 100, "the sweep actually produced seams to measure"); + + // Measured with the carry disabled: mean 4.00 and 90 awkward leaps in 800 + // seams. With it: 3.29 and 4. The awkward count is the assertion that + // matters, and the mean has the same caveat as the shape test above -- it + // rose from 2.97 when the unison was repriced, because a seam that used to + // repeat the note is now a real move rather than a zero dragging the mean + // down. + expect(wide * 100 < seams, juce::String(wide) + " awkward leaps across " + + juce::String(seams) + " seams"); + + // The seam stays WIDER than the line inside an interval, which measures + // about 2.0, and that is not a defect being tolerated. The contour is + // rerolled per interval, so a new phrase legitimately begins somewhere new + // -- a Fall handing over to a Rise aims twelve semitones away, and the + // interval cost should lose that argument. What must not survive is the + // seam being the most leap-prone moment in the melody, which is what 4.00 + // and 90 awkward leaps meant. + expect(mean < 3.7, "the boundary leaps more than a new phrase justifies: " + + juce::String(mean, 2)); + } + // Articulation is a separate decision from how long a note deserves to be, + // and the invariants are about what it must NOT do. + void runArticulation() { + beginTest("articulation moves the notes and nothing else"); + namespace m = chalkwalk::music; + + for (const char *name : {"C major", "D minor", "Bb Lydian"}) + for (std::uint32_t seed = 1; seed <= 12; ++seed) { + auto base = settingsFor(name, seed); + + // The line itself is note CHOICE, which articulation must not touch: + // it decides how long a note is held, never which one is played or + // whether it sounds at all. + const auto reference = BotBand::leadLine(base, 1); + for (int a : {0, 25, 50, 75, 100}) { + auto s = base; + s.articulation = a; + expect(BotBand::leadLine(s, 1) == reference, + juce::String(name) + ": articulation " + juce::String(a) + + " changed which notes are played"); + } + } + + beginTest("the default is the duration model untouched"); + // The migration constraint: a Settings nobody has touched must behave + // exactly as it did before this existed. + expectEquals(BotBand::Settings{}.articulation, m::kArticulationNatural, + "the default should be the natural setting"); + for (int hold = 1; hold <= 500; hold += 37) + for (int gap = 1; gap <= 500; gap += 53) + expectEquals(m::articulate(hold, gap, m::kArticulationNatural), + std::min(hold, gap), "the natural setting must not move"); + } +}; + +static LeadLineTests leadLineTests; diff --git a/test/LoopbackTests.cpp b/test/LoopbackTests.cpp index 798d6d7..98a7a3f 100644 --- a/test/LoopbackTests.cpp +++ b/test/LoopbackTests.cpp @@ -84,16 +84,16 @@ class LoopbackProtocolTests : public juce::UnitTest { "onConnected never fired"); auto authPayload = s.server.lastPayloadOfType(0x80); - expect(authPayload.getSize() >= 20, "no CLIENT_AUTH_USER received"); + expect(authPayload.size() >= 20, "no CLIENT_AUTH_USER received"); juce::uint8 expected[20]; NinjamProtocol::computeAuthHash("tester", "", s.server.challengeBytes(), expected); - expect(memcmp(authPayload.getData(), expected, 20) == 0, + expect(memcmp(authPayload.data(), expected, 20) == 0, "auth hash on the wire does not match"); // The username follows the hash, NUL-terminated. - const auto *b = static_cast(authPayload.getData()); + const auto *b = static_cast(authPayload.data()); expect(memcmp(b + 20, "tester\0", 7) == 0, "username malformed"); // CLIENT_SET_CHANNEL_INFO is sent immediately after the grant. @@ -106,11 +106,11 @@ class LoopbackProtocolTests : public juce::UnitTest { Session s; expect(s.connect(48000.0, 120, 8, "alice", "secret")); auto payload = s.server.lastPayloadOfType(0x80); - expect(payload.getSize() >= 20); + expect(payload.size() >= 20); juce::uint8 expected[20]; NinjamProtocol::computeAuthHash("alice", "secret", s.server.challengeBytes(), expected); - expect(memcmp(payload.getData(), expected, 20) == 0); + expect(memcmp(payload.data(), expected, 20) == 0); } beginTest("auth denial disconnects cleanly"); @@ -157,8 +157,8 @@ class LoopbackProtocolTests : public juce::UnitTest { "no CLIENT_SET_USERMASK after user info"); auto mask = s.server.lastPayloadOfType(0x81); - expectEquals((int)mask.getSize(), 5 + 4); // "peer\0" + 4-byte mask - const auto *b = static_cast(mask.getData()); + expectEquals((int)mask.size(), 5 + 4); // "peer\0" + 4-byte mask + const auto *b = static_cast(mask.data()); expect(memcmp(b, "peer\0", 5) == 0); expectEquals((int)b[5], 1, "channel 0 bit should be set"); } @@ -207,7 +207,7 @@ class LoopbackProtocolTests : public juce::UnitTest { expect(waitUntil([&] { return s.server.countReceived(0x81) > 0; })); auto mask = s.server.lastPayloadOfType(0x81); - const auto *b = static_cast(mask.getData()); + const auto *b = static_cast(mask.data()); // Channel 0 still on, channel 2 now off -> 0b0001. expectEquals((int)b[5], 1); @@ -215,7 +215,7 @@ class LoopbackProtocolTests : public juce::UnitTest { s.client.setRemoteUserRecv("peer", 2, true); expect(waitUntil([&] { return s.server.countReceived(0x81) > 0; })); auto mask2 = s.server.lastPayloadOfType(0x81); - const auto *b2 = static_cast(mask2.getData()); + const auto *b2 = static_cast(mask2.data()); expectEquals((int)b2[5], 5, "channels 0 and 2 -> 0b0101"); } @@ -264,17 +264,17 @@ class LoopbackProtocolTests : public juce::UnitTest { NinjamProtocol::Chat parsed; expect( NinjamProtocol::parseChat(s.server.lastPayloadOfType(0xC0), parsed)); - expectEquals(parsed.type, juce::String("MSG")); - expectEquals(parsed.p1, juce::String("hi from the client")); + expectEquals(juce::String(parsed.type), juce::String("MSG")); + expectEquals(juce::String(parsed.p1), juce::String("hi from the client")); s.server.clearReceived(); s.client.sendPrivateMessage("bob", "secret"); expect(waitUntil([&] { return s.server.countReceived(0xC0) > 0; })); expect( NinjamProtocol::parseChat(s.server.lastPayloadOfType(0xC0), parsed)); - expectEquals(parsed.type, juce::String("PRIVMSG")); - expectEquals(parsed.p1, juce::String("bob")); - expectEquals(parsed.p2, juce::String("secret")); + expectEquals(juce::String(parsed.type), juce::String("PRIVMSG")); + expectEquals(juce::String(parsed.p1), juce::String("bob")); + expectEquals(juce::String(parsed.p2), juce::String("secret")); } beginTest("chat log is capped at 100 entries"); @@ -305,7 +305,7 @@ class LoopbackProtocolTests : public juce::UnitTest { juce::String((int)elapsed) + " ms"); for (const auto &m : s.server.messagesOfType(0xFD)) - expectEquals((int)m.payload.getSize(), 0, + expectEquals((int)m.payload.size(), 0, "keep-alive must have an empty payload"); } diff --git a/test/MusicalKeyTests.cpp b/test/MusicalKeyTests.cpp deleted file mode 100644 index baadcd7..0000000 --- a/test/MusicalKeyTests.cpp +++ /dev/null @@ -1,154 +0,0 @@ -#include - -#include "MusicalKey.h" - -namespace { - -using namespace MusicalKey; - -class MusicalKeyTests : public juce::UnitTest { -public: - MusicalKeyTests() : juce::UnitTest("MusicalKey", "MusicalKey") {} - - void runTest() override { - beginTest("the shorthand people actually type"); - { - // What gets typed in a jam is "Dm", not "D minor". - const auto dm = parseName("Dm"); - expect(dm.valid); - expectEquals(displayName(dm), juce::String("D minor")); - - const auto d = parseName("D"); - expect(d.valid); - expectEquals(displayName(d), juce::String("D major"), - "a bare tonic means major"); - - expectEquals(displayName(parseName("Bb")), juce::String("Bb major")); - expectEquals(displayName(parseName("Bbm")), juce::String("Bb minor")); - expectEquals(displayName(parseName("F#")), juce::String("F# major")); - } - - beginTest("a flat in second position is never a mode"); - { - // "b" is the one character that could be either an accidental or the - // start of a mode name. No mode begins with it, so the reading is - // unambiguous -- but "Bm" must still be B minor, not B flat anything. - const auto bFlat = parseName("Bb"); - const auto bMinor = parseName("Bm"); - expect(bFlat.valid && bMinor.valid); - expectEquals(displayName(bFlat), juce::String("Bb major")); - expectEquals(displayName(bMinor), juce::String("B minor")); - expect(bFlat.tonic != bMinor.tonic, "Bb and B are different tonics"); - } - - beginTest("every mode round-trips through its own name"); - { - for (const auto *name : {"major", "minor", "Ionian", "Dorian", "Phrygian", - "Lydian", "Mixolydian", "Aeolian", "Locrian"}) { - const juce::String spelled = juce::String("D ") + name; - const auto key = parseName(spelled); - expect(key.valid, "did not parse: " + spelled); - expectEquals(displayName(key), spelled, - "did not round-trip: " + spelled); - } - } - - beginTest( - "minor and Aeolian stay distinct even though they are the same scale"); - { - // Someone who typed "D minor" should be told "D minor" back. The scales - // are identical; the words are not. - expectEquals(displayName(parseName("D minor")), juce::String("D minor")); - expectEquals(displayName(parseName("D Aeolian")), - juce::String("D Aeolian")); - expectEquals(scaleNotes(parseName("D minor")), - scaleNotes(parseName("D Aeolian")), - "the notes must be the same even if the names are not"); - } - - beginTest("case and spacing do not matter"); - { - for (const auto *s : {"dm", "DM", "D m", " Dm ", "d minor", "D MINOR"}) - expectEquals(displayName(parseName(s)), juce::String("D minor"), - juce::String("failed on: ") + s); - } - - beginTest("the scale is spelled to match the tonic"); - { - expectEquals(scaleNotes(parseName("D minor")), - juce::String("D E F G A Bb C")); - expectEquals(scaleNotes(parseName("C major")), - juce::String("C D E F G A B")); - // A mode is not just a relabelled major scale: F Dorian has four flats. - expectEquals(scaleNotes(parseName("F Dorian")), - juce::String("F G Ab Bb C D Eb")); - } - - beginTest("prose is never a key"); - { - // The whole reason the tagged form exists. Jamtaba's chord parser reads - // "I AM TIRED ..." as a progression because it treats I and l as measure - // separators -- that is in their own test suite. Guessing at prose gives - // you a header that lies. - for (const auto *s : {"I AM TIRED ...", "LETS TAKE A BREAK", "hello", "", - " ", "H minor", "D quantum", "8", "Dmm"}) - expect(!parseName(s).valid, - juce::String("wrongly read as a key: ") + s); - } - - beginTest("only the tagged form is picked up from a chat line"); - { - expect(!parseTagged("lets play in D minor").valid, - "free text must not set the key"); - expect(!parseTagged("key: D minor").valid, "the bracket is required"); - - const auto tagged = parseTagged("[key: D minor]"); - expect(tagged.valid); - expectEquals(displayName(tagged), juce::String("D minor")); - } - - beginTest("a tag is found wherever it sits in the line"); - { - // It arrives inside a topic that may say other things too. - for (const auto *s : - {"[key: Dm]", "jam night -- [key: Dm] -- all welcome", - "trailing [key: Dm]", "[KEY: Dm]"}) - expectEquals(displayName(parseTagged(s)), juce::String("D minor"), - juce::String("failed on: ") + s); - } - - beginTest("a malformed tag yields no key rather than a wrong one"); - { - for (const auto *s : {"[key:", "[key: ]", "[key: bananas]", "[key Dm]", - "[key: Dm", "]key: Dm["}) - expect(!parseTagged(s).valid, - juce::String("wrongly read as a key: ") + s); - } - - beginTest("what we send is what we parse"); - { - // The round trip that makes the convention work between two clients. - for (const auto *s : {"Dm", "F# Dorian", "Bb major", "C Locrian"}) { - const auto original = parseName(s); - expect(original.valid); - const auto message = buildTagged(original); - expect(message.startsWith("[key:")); - const auto received = parseTagged(message); - expect(received.valid, "did not survive the round trip: " + message); - expect(received == original, "changed in the round trip: " + message); - } - } - - beginTest("an invalid key builds and displays as nothing"); - { - Key none; - expect(buildTagged(none).isEmpty()); - expect(displayName(none).isEmpty()); - expect(scaleNotes(none).isEmpty()); - } - } -}; - -static MusicalKeyTests musicalKeyTests; - -} // namespace diff --git a/test/NinjamProtocolTests.cpp b/test/NinjamProtocolTests.cpp deleted file mode 100644 index c58e414..0000000 --- a/test/NinjamProtocolTests.cpp +++ /dev/null @@ -1,485 +0,0 @@ -#include - -#include "NinjamProtocol.h" - -#include - -namespace { - -using namespace NinjamProtocol; - -juce::MemoryBlock mb(std::initializer_list bytes) { - juce::MemoryBlock b; - for (int v : bytes) { - const juce::uint8 x = (juce::uint8)v; - b.append(&x, 1); - } - return b; -} - -juce::String hex(const juce::uint8 *d, int n) { - juce::String s; - for (int i = 0; i < n; ++i) - s += juce::String::toHexString((int)d[i]).paddedLeft('0', 2); - return s; -} - -class NinjamProtocolTests : public juce::UnitTest { -public: - NinjamProtocolTests() : juce::UnitTest("NinjamProtocol", "NinjamProtocol") {} - - // Feeds every strict prefix of a valid payload to a parser and requires that - // none of them is accepted as complete without also being safe. Run this - // binary under ASan to turn any over-read into a hard failure. - template - void truncationSweep(const juce::MemoryBlock &valid, Fn &&parse, - const juce::String &what) { - for (size_t n = 0; n < valid.getSize(); ++n) { - juce::MemoryBlock prefix(valid.getData(), n); - parse(prefix); // must not read out of bounds, must not crash - } - expect(true, what + " survived truncation sweep"); - } - - void runTest() override { - runFramingTests(); - runReaderTests(); - runParserTests(); - runTruncationTests(); - runBuilderTests(); - runAuthTests(); - } - - void runFramingTests() { - beginTest("frame header round-trip, little-endian length"); - { - juce::uint8 h[kHeaderSize]; - writeFrameHeader(h, 0xC0, 0x00030201); - expectEquals((int)h[0], 0xC0); - expectEquals((int)h[1], 0x01); - expectEquals((int)h[2], 0x02); - expectEquals((int)h[3], 0x03); - expectEquals((int)h[4], 0x00); - - FrameHeader out; - expect(readFrameHeader(h, out)); - expectEquals((int)out.type, 0xC0); - expectEquals((int)out.length, 0x00030201); - - // Zero-length payloads (KEEP_ALIVE) round-trip too. - writeFrameHeader(h, 0xFD, 0); - expect(readFrameHeader(h, out)); - expectEquals((int)out.type, 0xFD); - expectEquals((int)out.length, 0); - } - - beginTest("frame header rejects oversized length"); - { - juce::uint8 h[kHeaderSize]; - writeFrameHeader(h, 0x05, kMaxPayload + 1); - FrameHeader out; - expect(!readFrameHeader(h, out)); - - writeFrameHeader(h, 0x05, kMaxPayload); - expect(readFrameHeader(h, out)); - } - } - - void runReaderTests() { - beginTest("Reader refuses to read past the end"); - { - const juce::uint8 data[3] = {1, 2, 3}; - Reader r(data, 3); - juce::uint32 v32; - expect(!r.u32le(v32), "read 4 bytes from a 3-byte buffer"); - expect(!r.ok(), "cursor should latch failed"); - - Reader r2(data, 3); - juce::uint8 a, b, c, d; - expect(r2.u8(a) && r2.u8(b) && r2.u8(c)); - expect(!r2.u8(d)); - } - - beginTest("Reader::cstr requires a terminator inside the payload"); - { - const char unterminated[4] = {'a', 'b', 'c', 'd'}; - Reader r(unterminated, 4); - juce::String s; - expect(!r.cstr(s), "accepted a string with no NUL"); - - const char terminated[4] = {'a', 'b', 'c', '\0'}; - Reader r2(terminated, 4); - expect(r2.cstr(s)); - expectEquals(s, juce::String("abc")); - expect(r2.atEnd()); - } - - beginTest("Reader signed conversions"); - { - auto p = mb({0xFF, 0xFF, 0x80}); - Reader r(p.getData(), p.getSize()); - juce::int16 v16; - juce::int8 v8; - expect(r.i16le(v16)); - expectEquals((int)v16, -1); - expect(r.i8(v8)); - expectEquals((int)v8, -128); - } - - beginTest("Reader on empty and null buffers"); - { - Reader r(nullptr, 0); - juce::uint8 v; - expect(!r.u8(v)); - expect(r.atEnd()); - } - } - - void runParserTests() { - beginTest("0x02 server config is little-endian"); - { - // bpm = 120 (0x0078), bpi = 16 (0x0010), both little-endian. - auto p = mb({0x78, 0x00, 0x10, 0x00}); - ServerConfig cfg; - expect(parseServerConfig(p, cfg)); - expectEquals(cfg.bpm, 120); - expectEquals(cfg.bpi, 16); - } - - beginTest("0x01 auth reply"); - { - AuthReply r; - expect(parseAuthReply(mb({1}), r)); - expect(r.granted); - expect(parseAuthReply(mb({0}), r)); - expect(!r.granted); - expect(!parseAuthReply(juce::MemoryBlock(), r)); - } - - beginTest("0x03 user info round-trip with signed volume and pan"); - { - juce::MemoryBlock p; - const juce::uint8 head[6] = {1, 2, 0xFF, 0xFF, 0x80, 0x00}; - p.append(head, 6); // active, chIdx=2, volume=-1, pan=-128, flags=0 - p.append("alice\0", 6); - p.append("gtr\0", 4); - - std::vector entries; - expect(parseUserInfo(p, entries)); - expectEquals((int)entries.size(), 1); - expect(entries[0].active); - expectEquals(entries[0].channelIndex, 2); - expectEquals(entries[0].volume, -1); - expectEquals(entries[0].pan, -128); - expectEquals(entries[0].username, juce::String("alice")); - expectEquals(entries[0].channelName, juce::String("gtr")); - } - - beginTest("0x03 rejects a record with only four header bytes left"); - { - // The fixed part of a record is six bytes. The previous implementation - // checked for four and then read six, running two bytes past the end. - juce::MemoryBlock p; - const juce::uint8 head[4] = {1, 0, 0, 0}; - p.append(head, 4); - std::vector entries; - expect(!parseUserInfo(p, entries), "accepted a 4-byte record"); - } - - beginTest("0x03 rejects an unterminated username"); - { - juce::MemoryBlock p; - const juce::uint8 head[6] = {1, 0, 0, 0, 0, 0}; - p.append(head, 6); - p.append("alice", 5); // no NUL: the old code walked off the heap here - std::vector entries; - expect(!parseUserInfo(p, entries), "accepted an unterminated username"); - } - - beginTest("0x03 keeps entries parsed before a malformed record"); - { - juce::MemoryBlock p; - const juce::uint8 head[6] = {1, 0, 0, 0, 0, 0}; - p.append(head, 6); - p.append("bob\0", 4); - p.append("ch\0", 3); - p.append(head, 3); // truncated second record - std::vector entries; - expect(!parseUserInfo(p, entries)); - expectEquals((int)entries.size(), 1); - expectEquals(entries[0].username, juce::String("bob")); - } - - beginTest("0x04 download interval begin"); - { - juce::uint8 guid[16]; - for (int i = 0; i < 16; ++i) - guid[i] = (juce::uint8)(i * 17); - const char fourcc[4] = {'O', 'G', 'G', 'v'}; - auto p = buildIntervalBegin(guid, 4096, fourcc, 3, "carol"); - - IntervalBegin b; - expect(parseIntervalBegin(p, b)); - expectEquals((int)b.estimatedSize, 4096); - expectEquals(b.channelIndex, 3); - expectEquals(b.username, juce::String("carol")); - expect(b.isOggAudio()); - expectEquals(b.guidHex, hex(guid, 16)); - } - - beginTest("0x83 upload interval begin is exactly 25 bytes, no username"); - { - juce::uint8 guid[16] = {}; - const char fourcc[4] = {'O', 'G', 'G', 'v'}; - auto p = buildIntervalBegin(guid, 0, fourcc, 1); - expectEquals((int)p.getSize(), 25, "servers reject a longer 0x83"); - - IntervalBegin b; - expect(parseIntervalBegin(p, b)); - expectEquals(b.channelIndex, 1); - expect(b.username.isEmpty()); - } - - beginTest("non-OGGv fourcc is reported as non-audio"); - { - juce::uint8 guid[16] = {}; - const char jtbv[4] = {'J', 'T', 'B', 'v'}; // Jamtaba video - auto p = buildIntervalBegin(guid, 0, jtbv, 1, "dave"); - IntervalBegin b; - expect(parseIntervalBegin(p, b)); - expect(!b.isOggAudio()); - } - - beginTest("0x05 interval write flags and payload view"); - { - juce::uint8 guid[16] = {}; - guid[0] = 0xAB; - const juce::uint8 audio[5] = {1, 2, 3, 4, 5}; - - auto p = buildIntervalWrite(guid, false, audio, 5); - IntervalWrite w; - expect(parseIntervalWrite(p, w)); - expect(!w.isFinal); - expectEquals(w.audioSize, 5); - expect(memcmp(w.audioData, audio, 5) == 0); - - auto q = buildIntervalWrite(guid, true, nullptr, 0); - expectEquals((int)q.getSize(), 17); - expect(parseIntervalWrite(q, w)); - expect(w.isFinal); - expectEquals(w.audioSize, 0); - } - - beginTest("0xC0 chat round-trip and optional trailing fields"); - { - auto p = buildChat("PRIVMSG", "alice", "hi there"); - Chat c; - expect(parseChat(p, c)); - expectEquals(c.type, juce::String("PRIVMSG")); - expectEquals(c.p1, juce::String("alice")); - expectEquals(c.p2, juce::String("hi there")); - expect(c.p3.isEmpty()); - expect(c.p4.isEmpty()); - - // A sender that stops after two fields is legal. - juce::MemoryBlock q; - q.append("MSG\0", 4); - q.append("bob\0", 4); - expect(parseChat(q, c)); - expectEquals(c.type, juce::String("MSG")); - expectEquals(c.p1, juce::String("bob")); - expect(c.p2.isEmpty()); - } - - beginTest("0xC0 rejects a present-but-unterminated field"); - { - juce::MemoryBlock p; - p.append("MSG\0", 4); - p.append("bob", 3); // started a field, never terminated it - Chat c; - expect(!parseChat(p, c)); - } - } - - void runTruncationTests() { - beginTest("every parser survives every truncation"); - // The parsers must be total. Any over-read here is a heap read past the end - // of a MemoryBlock that is sized to exactly the payload length and is not - // NUL-padded. - juce::uint8 guid[16]; - for (int i = 0; i < 16; ++i) - guid[i] = (juce::uint8)(i + 1); - const char fourcc[4] = {'O', 'G', 'G', 'v'}; - const juce::uint8 audio[8] = {1, 2, 3, 4, 5, 6, 7, 8}; - - juce::MemoryBlock userInfo; - const juce::uint8 head[6] = {1, 0, 0x10, 0x00, 0x20, 0x00}; - userInfo.append(head, 6); - userInfo.append("alice\0", 6); - userInfo.append("guitar\0", 7); - userInfo.append(head, 6); - userInfo.append("bob\0", 4); - userInfo.append("bass\0", 5); - - truncationSweep( - mb({1, 2, 3, 4, 5, 6, 7, 8}), - [](const juce::MemoryBlock &p) { - AuthChallenge c; - parseAuthChallenge(p, c); - }, - "0x00"); - truncationSweep( - mb({1}), - [](const juce::MemoryBlock &p) { - AuthReply r; - parseAuthReply(p, r); - }, - "0x01"); - truncationSweep( - mb({0x78, 0x00, 0x10, 0x00}), - [](const juce::MemoryBlock &p) { - ServerConfig c; - parseServerConfig(p, c); - }, - "0x02"); - truncationSweep( - userInfo, - [](const juce::MemoryBlock &p) { - std::vector e; - parseUserInfo(p, e); - }, - "0x03"); - truncationSweep( - buildIntervalBegin(guid, 1234, fourcc, 2, "alice"), - [](const juce::MemoryBlock &p) { - IntervalBegin b; - parseIntervalBegin(p, b); - }, - "0x04"); - truncationSweep( - buildIntervalWrite(guid, true, audio, 8), - [](const juce::MemoryBlock &p) { - IntervalWrite w; - parseIntervalWrite(p, w); - }, - "0x05"); - truncationSweep( - buildChat("PRIVMSG", "alice", "hello", "x", "y"), - [](const juce::MemoryBlock &p) { - Chat c; - parseChat(p, c); - }, - "0xC0"); - - beginTest("parsers survive random garbage"); - { - juce::Random rng(1234); - for (int iter = 0; iter < 2000; ++iter) { - juce::MemoryBlock p((size_t)rng.nextInt(64), false); - for (size_t i = 0; i < p.getSize(); ++i) - p[i] = (char)rng.nextInt(256); - - AuthChallenge ac; - parseAuthChallenge(p, ac); - AuthReply ar; - parseAuthReply(p, ar); - ServerConfig sc; - parseServerConfig(p, sc); - std::vector ui; - parseUserInfo(p, ui); - IntervalBegin ib; - parseIntervalBegin(p, ib); - IntervalWrite iw; - parseIntervalWrite(p, iw); - Chat ch; - parseChat(p, ch); - } - expect(true); - } - } - - void runBuilderTests() { - beginTest("0x80 auth packet layout"); - { - juce::uint8 hash[20]; - for (int i = 0; i < 20; ++i) - hash[i] = (juce::uint8)i; - auto p = buildAuthUser(hash, "tester"); - expectEquals((int)p.getSize(), 20 + 7 + 4 + 4); - - const auto *b = static_cast(p.getData()); - expect(memcmp(b, hash, 20) == 0); - expect(memcmp(b + 20, "tester\0", 7) == 0); - // caps = 1 LE, version = 0x00020000 LE - expectEquals((int)b[27], 1); - expectEquals((int)b[28], 0); - expectEquals((int)b[29], 0); - expectEquals((int)b[30], 0); - expectEquals((int)b[31], 0); - expectEquals((int)b[32], 0); - expectEquals((int)b[33], 0x02); - expectEquals((int)b[34], 0); - } - - beginTest("0x81 usermask bitmask layout"); - { - // Channels 0, 3 and 5 enabled -> 0b101001 = 0x29. - std::vector> masks{{"alice", 0x29}}; - auto p = buildUsermask(masks); - expectEquals((int)p.getSize(), 6 + 4); - const auto *b = static_cast(p.getData()); - expect(memcmp(b, "alice\0", 6) == 0); - expectEquals((int)b[6], 0x29); - expectEquals((int)b[7], 0); - expectEquals((int)b[8], 0); - expectEquals((int)b[9], 0); - } - - beginTest("0x82 channel info layout with mpisize"); - { - auto p = buildChannelInfo({"gtr", "bass"}); - // 2 (mpisize) + 4 ("gtr\0") + 4 (meta) + 5 ("bass\0") + 4 (meta) - expectEquals((int)p.getSize(), 19); - const auto *b = static_cast(p.getData()); - expectEquals((int)b[0], 4); - expectEquals((int)b[1], 0); - expect(memcmp(b + 2, "gtr\0", 4) == 0); - for (int i = 6; i < 10; ++i) - expectEquals((int)b[i], 0); - expect(memcmp(b + 10, "bass\0", 5) == 0); - } - - beginTest("0x82 with no channels is just the mpisize header"); - { - expectEquals((int)buildChannelInfo({}).getSize(), 2); - } - } - - void runAuthTests() { - beginTest("auth hash matches an independent SHA1 implementation"); - // Goldens computed with Python hashlib, not with our own Sha1 class: - // sha1(sha1(user + ":" + pass) + challenge) - juce::uint8 challenge[8]; - for (int i = 0; i < 8; ++i) - challenge[i] = (juce::uint8)i; - - juce::uint8 out[20]; - - computeAuthHash("tester", "", challenge, out); - expectEquals(hex(out, 20), - juce::String("0471f0ad9885d825ce678e75cf23668c994068f8")); - - computeAuthHash("alice", "secret", challenge, out); - expectEquals(hex(out, 20), - juce::String("7f5c31b13ebe89c36c8e3b5ee59720e238bb6422")); - - // The anonymous login form used by the server browser. - computeAuthHash("anonymous:bob", "", challenge, out); - expectEquals(hex(out, 20), - juce::String("81d28bdad1230452f6ae94f940c9f9ce94b4d0b4")); - } -}; - -static NinjamProtocolTests ninjamProtocolTests; - -} // namespace diff --git a/test/PracticeRoomTests.cpp b/test/PracticeRoomTests.cpp new file mode 100644 index 0000000..65c4080 --- /dev/null +++ b/test/PracticeRoomTests.cpp @@ -0,0 +1,1474 @@ +#include +#include "../src/NinjamBotClient.h" +#include "../src/NinjamClient.h" +#include +#include "../src/PracticeRoom.h" +#include "FakeNinjamServer.h" // for waitUntil +#include +#include + +// Two things are under test here, and the second matters more than it looks. +// +// That the room works: you connect to 127.0.0.1 like any server and the bots +// arrive as ordinary remote players. +// +// And that the bots are easy to get rid of. They can be pointed at a real +// server, so the failure mode to design against is a bot nobody can evict, +// playing to a room that never asked for it. Every exit route gets a test. + +namespace { + +struct Joiner : public NinjamClientListener { + NinjamClient client; + std::atomic userInfoChanges{0}; + juce::CriticalSection lock; + juce::Array chats; + + Joiner() { client.addListener(this); } + ~Joiner() override { + client.removeListener(this); + client.disconnectFromServer(); + } + + void onUserInfoChange() override { userInfoChanges.fetch_add(1); } + void onChatMessage(const juce::String &type, const juce::String &username, + const juce::String &text) override { + juce::ScopedLock sl(lock); + chats.add(type + "|" + username + "|" + text); + } + + juce::Array snapshot() const { + juce::ScopedLock sl(lock); + return chats; + } + + bool join(const PracticeRoom &room, const juce::String &name) { + client.setSampleRate(48000.0); + client.connectToServer(PracticeRoom::host(), room.port(), name, ""); + return waitUntil([this] { return client.isConnected(); }, 5000); + } +}; + +// The bot playing a given instrument, whatever it happens to be called this +// session. Names come from the seed now, so a test that wants "the keys bot" +// has to ask rather than assume. +juce::String botPlaying(const PracticeRoom &room, + const juce::String &instrument) { + for (const auto &n : room.botNames()) + if (n.contains("[" + instrument + "-bot]")) + return n; + return {}; +} + +MusicalKey::Key keyOf(const std::string &name) { + auto k = MusicalKey::parseName(name); + jassert(k.valid); + return k; +} + +// The band introduces itself a few seconds after the first human arrives, so a +// test that starts talking straight away races the roster and counts it as a +// reply. Wait for it to land instead of filtering it out afterwards -- the +// roster is a real thing the room says, and a test that ignored it could not +// tell it apart from a bot answering twice. +// The band arrives silent now, so anything about playing has to start it. +bool startBand(Joiner &you, const PracticeRoom &room) { + you.client.sendChatMessage("band play"); + return waitUntil( + [&] { + const auto phases = room.bandPhases(); + if (phases.empty()) + return false; + for (auto p : phases) + if (p != BandPlayState::State::Playing) + return false; + return true; + }, + 6000); +} + +bool waitForRoster(const Joiner &you) { + return waitUntil( + [&] { + for (const auto &line : you.snapshot()) + if (juce::String(line).contains("say a name to talk to one of us")) + return true; + return false; + }, + 12000); +} + +PracticeRoom::Config testConfig(const juce::String &owner = "you") { + PracticeRoom::Config c; + c.bpm = 120; + c.bpi = 8; + c.sampleRate = 48000.0; + c.ownerName = owner; + // Minutes of grace are right for a person whose connection dropped and wrong + // for a test: what is under test is that the countdown runs and what stops + // it, never how long three minutes is. + c.ownerGraceMs = 1200; + c.initialGraceMs = 60000; + return c; +} + +} // namespace + +class PracticeRoomTests : public juce::UnitTest { +public: + PracticeRoomTests() : juce::UnitTest("PracticeRoom", "networking") {} + + void runTest() override { + runStartupTests(); + runBotVisibilityTests(); + runPartCommandTests(); + runBandFollowingTests(); + runOwnerDepartureTests(); + runConnectionLossTests(); + } + + void runStartupTests() { + beginTest("a room starts, binds loopback, and brings a band"); + { + PracticeRoom room; + expect(room.start(testConfig())); + expect(room.isRunning()); + expect(room.port() > 0); + expectEquals(juce::String(PracticeRoom::host()), + juce::String("127.0.0.1")); + expect(room.botCount() > 0, "the room brought no bots"); + } + + beginTest("a nonsensical tempo is refused rather than guessed at"); + { + PracticeRoom room; + auto bad = testConfig(); + bad.bpm = 0; + expect(!room.start(bad)); + expect(!room.isRunning()); + expectEquals(room.port(), 0, "a refused start left a socket open"); + } + + beginTest("starting twice is safe and leaves one room"); + { + PracticeRoom room; + expect(room.start(testConfig())); + const int first = room.port(); + expect(room.start(testConfig())); + expect(room.isRunning()); + expect(room.port() != first || first == 0, + "the second start reused a stale port"); + room.stop(); + expect(!room.isRunning()); + } + + beginTest("stop is idempotent"); + { + PracticeRoom room; + expect(room.start(testConfig())); + room.stop(); + room.stop(); + expect(!room.isRunning()); + } + } + + void runBotVisibilityTests() { + beginTest("bots arrive as ordinary remote players"); + { + // The whole point: nothing in the client knows this room is special. + PracticeRoom room; + expect(room.start(testConfig("you"))); + + Joiner you; + expect(you.join(room, "you")); + + const auto expected = room.botNames(); + expect(expected.size() > 0); + + expect(waitUntil( + [&] { + auto users = you.client.getRemoteUsers(); + for (const auto &n : expected) + if (users.count(n) == 0) + return false; + return true; + }, + 5000), + "the band never appeared in the mixer"); + + auto users = you.client.getRemoteUsers(); + expect(users[expected[0]].channels.size() > 0, + "a bot arrived with no channels"); + } + + beginTest("bot names say they are bots, and can be sent a message"); + { + // A human reading the mixer deserves to know which strips are not people, + // and every client sends a private message by splitting on the first + // space -- so a name with one in it cannot be reached at all. Both + // properties are checked here because the second is invisible until + // somebody tries to talk to a bot and nothing happens. + PracticeRoom room; + expect(room.start(testConfig())); + + juce::StringArray handles; + for (const auto &n : room.botNames()) { + expect(n.endsWith("-bot]"), "bot name does not identify itself: " + n); + expect(!n.containsChar(' '), + "a name with a space cannot be sent a private message: " + n); + + // The handle is what a player types to address it, and two bots + // sharing one would make both unaddressable. + const auto handle = juce::String(BotNames::handleOf(n.toStdString())); + expect(juce::String(handle).isNotEmpty(), "no handle in " + n); + expect(!handles.contains(handle), + "two bots answer to the same handle: " + handle); + handles.add(handle); + } + expectEquals(handles.size(), 4); + } + } + + void runPartCommandTests() { + beginTest("the part commands are recognised, and nothing else is"); + { + expect(PracticeBot::isPartCommand("leave")); + expect(PracticeBot::isPartCommand("exit")); + expect(PracticeBot::isPartCommand("go away")); + expect(PracticeBot::isPartCommand("go home")); + expect(PracticeBot::isPartCommand(" LEAVE "), "not trimmed or folded"); + + // Withdrawn: "part" is the most ordinary word in a jam, and using it for + // a destructive command put it one word from "what's your part". + expect(!PracticeBot::isPartCommand("part")); + expect(!PracticeBot::isPartCommand("whats your part")); + + // Withdrawn for the same reason, and it was the worse of the two: to a + // musician "stop" is the least destructive thing you can say, and it + // sent the whole band home. It means stop PLAYING now + // (docs/BOT-CHAT.md section 15). Bare "go" goes with it -- on its own it + // is as likely to mean start. + expect(!PracticeBot::isPartCommand("stop")); + expect(!PracticeBot::isPartCommand("go")); + + expect(!PracticeBot::isPartCommand("particularly")); + expect(!PracticeBot::isPartCommand("please leave")); + expect(!PracticeBot::isPartCommand("")); + } + + beginTest("the help line says how to remove the bot"); + { + const auto help = PracticeBot::helpLine("Mirn[kit-bot]"); + expect(juce::String(help).contains("Mirn[kit-bot]")); + expect(juce::String(help).contains("leave"), + "help does not name the command"); + } + + beginTest( + "a private message parts a bot, from someone who does not own it"); + { + // Anyone in the room may evict a bot. Needing to find its owner first is + // exactly the annoyance being avoided. + PracticeRoom room; + expect(room.start(testConfig("you"))); + + Joiner owner, stranger; + expect(owner.join(room, "you")); + expect(stranger.join(room, "someone-else")); + + const auto botName = room.botNames()[0]; + expect(waitUntil( + [&] { + return stranger.client.getRemoteUsers().count(botName) > 0; + }, + 5000), + "the bot never appeared"); + + stranger.client.sendPrivateMessage(botName, "leave"); + + expect(waitUntil( + [&] { + return stranger.client.getRemoteUsers().count(botName) == 0; + }, + 5000), + "the bot ignored a part request from a non-owner"); + } + + beginTest("a bot answers help privately"); + { + PracticeRoom room; + expect(room.start(testConfig("you"))); + Joiner you; + expect(you.join(room, "you")); + + const auto botName = room.botNames()[0]; + expect(waitUntil( + [&] { return you.client.getRemoteUsers().count(botName) > 0; }, + 5000)); + + you.client.sendPrivateMessage(botName, "help"); + expect(waitUntil( + [&] { + for (const auto &line : you.snapshot()) + if (juce::String(line).startsWith("PRIVMSG|" + botName) && + juce::String(line).contains("leave")) + return true; + return false; + }, + 5000), + "the bot did not explain how to remove it"); + } + } + + void runOwnerDepartureTests() { + beginTest("an empty room takes the band with it, after the grace"); + { + // The rule that matters most on a real server: walking away is enough to + // clean up after yourself, with nothing to remember. + // + // Not INSTANTLY, which it used to be. A part was terminal, there is no + // reconnect by design and the room reaped the object, so a thirty-second + // blip did not lose the band for thirty seconds -- it destroyed it, and + // the room ran on with nothing in it (docs/BOT-CHAT.md section 15). + PracticeRoom room; + expect(room.start(testConfig("you"))); + + { + Joiner you; + expect(you.join(room, "you")); + expect(waitUntil( + [&] { + return you.client.getRemoteUsers().count( + room.botNames()[0]) > 0; + }, + 5000), + "the bot never appeared"); + // `you` disconnects here, leaving nobody at all. + } + + // Still there immediately afterwards: the grace is the whole point. + juce::MessageManager::getInstance()->runDispatchLoopUntil(300); + expect(room.botCount() > 0, "the band went the instant the room emptied"); + + expect(waitUntil([&] { return room.botCount() == 0; }, 8000), + "the band outlived the empty room"); + } + + beginTest("a blip does not lose the band"); + { + // The case the grace exists for. Leave and come back inside it and the + // band is still there -- silent, because there was nobody to play to, + // and waiting to be asked. + PracticeRoom room; + auto cfg = testConfig("you"); + cfg.ownerGraceMs = 4000; + expect(room.start(cfg)); + + { + Joiner you; + expect(you.join(room, "you")); + expect(waitUntil( + [&] { + return you.client.getRemoteUsers().count( + room.botNames()[0]) > 0; + }, + 5000), + "the bot never appeared"); + } + + juce::MessageManager::getInstance()->runDispatchLoopUntil(800); + expect(room.botCount() > 0, "the band did not survive the blip"); + + Joiner back; + expect(back.join(room, "you")); + expect(waitUntil( + [&] { + return back.client.getRemoteUsers().count( + room.botNames()[0]) > 0; + }, + 5000), + "the band was gone when the player came back"); + + // The room says the band is still there and how to start it. Not a + // separate "welcome back" line: the arrival roster already re-arms for + // the first human in a room, which on a reconnect is you -- so a line of + // our own would say what the roster is about to say anyway. + expect(waitUntil( + [&] { + for (const auto &line : back.snapshot()) + if (juce::String(line).contains("-bot]") && + juce::String(line).containsIgnoreCase("play")) + return true; + return false; + }, + 12000), + "nothing told the returning player the band was still there"); + + // ...and the countdown really was cancelled, rather than merely + // outrun: past the original expiry, they are still here. + juce::MessageManager::getInstance()->runDispatchLoopUntil(5000); + expect(room.botCount() > 0, + "the band left anyway after the player came back"); + } + + beginTest("a room that still has people in it keeps its band"); + { + // The owner is who summoned the band, not who it plays for. Stopping + // four voices because one person's router hiccuped disrupts everybody + // who did not drop -- and nothing leaks, because anyone present can send + // them home. + PracticeRoom room; + expect(room.start(testConfig("you"))); + + Joiner watcher; + expect(watcher.join(room, "watcher")); + const auto botName = room.botNames()[0]; + + { + Joiner you; + expect(you.join(room, "you")); + expect(waitUntil( + [&] { + return watcher.client.getRemoteUsers().count(botName) > 0; + }, + 5000), + "the bot never appeared"); + } + + // Well past the grace, and still playing for the room. + juce::MessageManager::getInstance()->runDispatchLoopUntil(3000); + expect(watcher.client.getRemoteUsers().count(botName) > 0, + "the band left a room that still had people in it"); + + // And whoever is left can still get rid of them, which is what makes + // staying safe rather than a bot nobody can remove. + watcher.client.sendChatMessage("leave"); + expect(waitUntil([&] { return room.botCount() == 0; }, 8000), + "the band could not be dismissed by whoever was left"); + } + + beginTest("an owner who comes and goes unseen still takes the bots"); + { + // The regression test for a race that made the suite intermittently + // flaky and, on a real server, would have made a bot immortal. + // + // `roomMembers` is maintained on the NETWORK thread the instant a JOIN or + // PART arrives; listener callbacks reach the bot on the MESSAGE thread + // afterwards. So a bot that answers "is my owner here?" by scanning that + // set is asking about a list which may already have moved on -- and an + // owner who joins and leaves inside one message-thread gap was, as far as + // the scan can tell, never there at all. The bot never records having + // seen them, so it never leaves. + // + // Reproducing that needs the gap to be real rather than hoped for, which + // is why this test does its joining and leaving WITHOUT pumping the + // message loop: `juce::Thread::sleep` on the message thread lets the + // network thread run and dispatches nothing. Both events are therefore + // certain to be processed before any callback fires. An earlier version + // of this test used the ordinary helper, which pumps, and consequently + // passed with the bug still in place. + PracticeRoom room; + expect(room.start(testConfig("you"))); + + const auto botName = room.botNames()[0]; + expect(waitUntil([&] { return room.botCount() > 0; }, 5000), + "the bot never appeared"); + + { + NinjamClient you; + you.setSampleRate(48000.0); + you.connectToServer(PracticeRoom::host(), room.port(), "you", ""); + juce::Thread::sleep(700); // on the wire, off the message loop + you.disconnectFromServer(); + juce::Thread::sleep(300); + } + + juce::ignoreUnused(botName); + expect(waitUntil([&] { return room.botCount() == 0; }, 8000), + "a bot outlived an owner it never saw arrive"); + } + + beginTest("a bot does not leave before its owner has ever arrived"); + { + // Bots connect before the player does, so "owner absent" must not mean + // "owner has left" until the owner has actually been seen. + PracticeRoom room; + expect(room.start(testConfig("you"))); + + juce::MessageManager::getInstance()->runDispatchLoopUntil(700); + expect(room.botCount() > 0, + "the band left before the player ever turned up"); + } + } + + void runBandFollowingTests() { + beginTest("the room brings a full band, each voice on its own channel"); + { + // A rhythm section and a lead, so any one part can be muted or sent home + // and played by a person instead. + PracticeRoom room; + expect(room.start(testConfig())); + expectEquals(room.botCount(), BotBand::kNumVoices); + + // Which NAME goes to which instrument comes from the room seed, so the + // assertion is about the instruments being covered rather than about any + // particular player turning up. + const auto names = room.botNames(); + for (const char *instrument : {"kit", "bass", "keys", "lead"}) { + int found = 0; + for (const auto &n : names) + if (n.contains(juce::String("[") + instrument + "-bot]")) + ++found; + expectEquals(found, 1, + juce::String("no single bot plays ") + instrument + ": " + + names.joinIntoString(", ")); + } + } + + beginTest("shake changes the figures"); + { + PracticeBot bot("Mirn[kit-bot]", {"kit"}, + std::make_unique()); + bot.playAs(BotBand::Voice::Drums, MusicalKey::parseName("C major"), 120, + 8, 48000.0, 7); + const auto before = bot.currentSettings().seed; + bot.shake(); + const auto after = bot.currentSettings().seed; + expect(before != after, "shake did not change the seed"); + expect(std::abs((long long)before - (long long)after) > 1000, + "shake produced an adjacent seed"); + } + + beginTest("the shake words are recognised, and nothing else is"); + { + expect(PracticeBot::isShakeCommand("shake")); + expect(PracticeBot::isShakeCommand("new")); + expect(PracticeBot::isShakeCommand(" AGAIN ")); + expect(!PracticeBot::isShakeCommand("shaken")); + expect(!PracticeBot::isShakeCommand("news")); + expect(!PracticeBot::isShakeCommand("")); + } + + beginTest("the band introduces itself once, and only once"); + { + // The one line every player is guaranteed to read, and the only answer to + // "how would anybody know they can talk to these things". Four separate + // "X here" lines would read as four processes starting; one roster reads + // as a band arriving. + PracticeRoom room; + expect(room.start(testConfig("you"))); + + Joiner you; + expect(you.join(room, "you")); + expect(waitUntil( + [&] { + return you.client.getRemoteUsers().count( + botPlaying(room, "keys")) > 0; + }, + 5000), + "the band never arrived"); + + // Five seconds of deliberate delay, plus room to be late. + expect(waitUntil( + [&] { + for (const auto &line : you.snapshot()) + if (juce::String(line).contains("The Understudies")) + return true; + return false; + }, + 9000), + "no roster was ever posted"); + + juce::StringArray roster, instructions, introductions; + for (const auto &line : you.snapshot()) { + if (!juce::String(line).startsWith("MSG|") || + !juce::String(line).contains("-bot]")) + continue; + if (juce::String(line).contains("The Understudies")) + roster.add(line); + else if (juce::String(line).contains("say a name")) + instructions.add(line); + else if (juce::String(line).contains("joining the others")) + introductions.add(line); + } + + expectEquals(roster.size(), 1, + "the roster was posted " + juce::String(roster.size()) + + " times: " + roster.joinIntoString(" / ")); + expectEquals(instructions.size(), 1, + "instructions posted more than once"); + expect(introductions.isEmpty(), + "a bot introduced itself as well as being on the roster: " + + introductions.joinIntoString(" / ")); + + // Every player is named, with what they play, so the room is legible. + for (const auto &n : room.botNames()) { + const auto handle = juce::String(BotNames::handleOf(n.toStdString())); + expect(roster[0].containsIgnoreCase(handle), + handle + " is missing from the roster: " + roster[0]); + } + + // And it leads with the interesting thing. A first-time player who types + // the first command they are shown should not empty their own room. + const int nameAt = instructions[0].indexOf("say a name"); + const int partAt = instructions[0].indexOf("leave"); + expect(nameAt >= 0 && partAt > nameAt, + "the eviction command is offered before the interesting one: " + + instructions[0]); + } + + beginTest("a bot that was never announced announces the band itself"); + { + // The case a tiebreak cannot handle, and the reason the rule is "announce + // unless somebody announced ME" rather than "announce if you are first". + // + // A bot joining after the roster has gone out was not in it, so it says + // so -- and it names the band it can SEE, which by then is everybody. The + // announcement lands when the band is complete rather than being lost + // because the moment passed. + PracticeRoom room; + expect(room.start(testConfig("you"))); + + Joiner you; + expect(you.join(room, "you")); + expect(waitUntil( + [&] { + for (const auto &line : you.snapshot()) + if (juce::String(line).contains("The Understudies")) + return true; + return false; + }, + 9000), + "no first roster"); + + const int before = you.snapshot().size(); + + // A latecomer, arriving well after the roster it was not part of. + PracticeBot late("Vurn[horn-bot]", {"horn"}, + std::make_unique()); + late.playAs(BotBand::Voice::Lead, MusicalKey::parseName("C major"), 120, + 8, 48000.0, 77u); + expect(late.join(PracticeRoom::host(), room.port(), 48000.0)); + + juce::String second; + expect(waitUntil( + [&] { + const auto lines = you.snapshot(); + for (int i = before; i < lines.size(); ++i) + if (lines[i].startsWith("MSG|Vurn[horn-bot]|")) { + second = lines[i]; + return true; + } + return false; + }, + 9000), + "the latecomer never introduced itself"); + + // And it named the WHOLE room, not just itself. + expect(second.containsIgnoreCase("vurn"), + "it left itself out: " + second); + int named = 0; + for (const auto &n : room.botNames()) + if (second.containsIgnoreCase( + juce::String(BotNames::handleOf(n.toStdString())))) + ++named; + expect(named >= 3, "the latecomer announced only itself: " + second); + + // Nobody who was already announced said anything again. + juce::StringArray extra; + const auto lines = you.snapshot(); + for (int i = before; i < lines.size(); ++i) + if (lines[i].startsWith("MSG|") && lines[i].contains("-bot]") && + !lines[i].startsWith("MSG|Vurn[horn-bot]|")) + extra.add(lines[i]); + expect(extra.isEmpty(), "an already-announced bot spoke again: " + + extra.joinIntoString(" / ")); + + late.part(); + } + + beginTest("nobody answers a question that was not aimed at anybody"); + { + // The failure this whole addressing layer exists to prevent, tested end + // to end rather than in the corpus: four bots answering one question. + PracticeRoom room; + expect(room.start(testConfig("you"))); + + Joiner you; + expect(you.join(room, "you")); + expect(waitUntil( + [&] { + return you.client.getRemoteUsers().count( + botPlaying(room, "keys")) > 0; + }, + 5000), + "the band never arrived"); + + const int before = you.snapshot().size(); + you.client.sendChatMessage("what are you playing"); + you.client.sendChatMessage("what key are we in"); + you.client.sendChatMessage("the bass is a bit loud"); + + // Give them every chance to misbehave. + juce::MessageManager::getInstance()->runDispatchLoopUntil(1500); + + juce::StringArray fromBots; + for (const auto &line : you.snapshot()) + if (juce::String(line).startsWith("MSG|") && + juce::String(line).contains("-bot]")) + fromBots.add(line); + expect(fromBots.isEmpty(), "unaddressed chat was answered: " + + fromBots.joinIntoString(" / ")); + expect(you.snapshot().size() >= before); + } + + beginTest("addressing a bot by name gets exactly that bot"); + { + PracticeRoom room; + expect(room.start(testConfig("you"))); + + Joiner you; + expect(you.join(room, "you")); + const auto keys = botPlaying(room, "keys"); + expect( + waitUntil([&] { return you.client.getRemoteUsers().count(keys) > 0; }, + 5000), + "the band never arrived"); + + // Its name alone, which is the opener: it should say what it is playing. + const auto handle = juce::String(BotNames::handleOf(keys.toStdString())); + you.client.sendChatMessage(handle); + + expect(waitUntil( + [&] { + for (const auto &line : you.snapshot()) + if (juce::String(line).startsWith("MSG|" + keys + "|")) + return true; + return false; + }, + 4000), + "the bot did not answer to its own name"); + + // And nobody else did. + juce::MessageManager::getInstance()->runDispatchLoopUntil(800); + juce::StringArray others; + for (const auto &line : you.snapshot()) + if (juce::String(line).startsWith("MSG|") && + juce::String(line).contains("-bot]") && + !juce::String(line).startsWith("MSG|" + keys + "|")) + others.add(line); + expect(others.isEmpty(), + "another bot answered too: " + others.joinIntoString(" / ")); + } + + beginTest("an addressed question is answered with the answer"); + { + // The counterpart to "nobody answers a question that was not aimed at + // anybody". That test can pass with the whole answering path dead, and + // for a while it was the only one over a real socket: silence proves + // restraint and nothing else. + PracticeRoom room; + auto cfg = testConfig("you"); + cfg.key = MusicalKey::parseName("D minor"); + expect(room.start(cfg)); + + Joiner you; + expect(you.join(room, "you")); + const auto keys = botPlaying(room, "keys"); + expect( + waitUntil([&] { return you.client.getRemoteUsers().count(keys) > 0; }, + 5000), + "the band never arrived"); + + const auto handle = juce::String(BotNames::handleOf(keys.toStdString())); + you.client.sendChatMessage(handle + ": what key are we in"); + + expect(waitUntil( + [&] { + for (const auto &line : you.snapshot()) + if (juce::String(line).startsWith("MSG|" + keys + "|") && + juce::String(line).containsIgnoreCase("D minor")) + return true; + return false; + }, + 4000), + "the bot did not say what key the room was in"); + } + + beginTest("the band arrives silent, and the roster says how to start it"); + { + // Bots connect before the player does, so a band that played on connect + // played to an empty room -- encoding and sending a full interval every + // few seconds to nobody for as long as it took you to arrive. Arriving + // silent also disposes of the wait-forever cost entirely + // (docs/BOT-CHAT.md section 15). + PracticeRoom room; + expect(room.start(testConfig("you"))); + + Joiner you; + expect(you.join(room, "you")); + expect(waitUntil( + [&] { + return you.client.getRemoteUsers().count( + botPlaying(room, "keys")) > 0; + }, + 5000), + "the band never arrived"); + expect(waitForRoster(you), "the band never introduced itself"); + + for (auto p : room.bandPhases()) + expect(p == BandPlayState::State::Silent, + "a bot started playing without being asked"); + + // A room where nothing happens looks broken, so the one line anybody + // reads has to carry the way in. + bool taught = false; + for (const auto &line : you.snapshot()) + if (juce::String(line).contains("-bot]") && + juce::String(line).containsIgnoreCase("play")) + taught = true; + expect(taught, "nothing told the room how to start the band"); + + you.client.sendChatMessage("band play"); + expect(waitUntil( + [&] { + for (auto p : room.bandPhases()) + if (p != BandPlayState::State::Playing) + return false; + return !room.bandPhases().empty(); + }, + 5000), + "the band would not start"); + } + + beginTest("one bot speaks for the band, and all four still act"); + { + // Reported from a real room: "band stop" got four identical replies. + // Acting is collective -- every bot ends the tune -- and only the LINE + // about it is rationed (docs/BOT-CHAT.md section 5). + PracticeRoom room; + expect(room.start(testConfig("you"))); + + Joiner you; + expect(you.join(room, "you")); + expect(waitUntil( + [&] { + return you.client.getRemoteUsers().count( + botPlaying(room, "keys")) > 0; + }, + 5000), + "the band never arrived"); + + auto botLinesSince = [&](int from) { + juce::StringArray out; + const auto all = you.snapshot(); + for (int i = from; i < all.size(); ++i) + if (all[i].startsWith("MSG|") && all[i].contains("-bot]")) + out.add(all[i]); + return out; + }; + + expect(waitForRoster(you), "the band never introduced itself"); + expect(startBand(you, room), "the band would not start"); + + const int before = you.snapshot().size(); + you.client.sendChatMessage("band stop"); + juce::MessageManager::getInstance()->runDispatchLoopUntil(2500); + + const auto replies = botLinesSince(before); + expectEquals(replies.size(), 1, + "the band answered as a chorus: " + + replies.joinIntoString(" / ")); + if (replies.size() == 1) + expect(replies[0].containsIgnoreCase("we"), + "the one reply does not speak for the band: " + replies[0]); + + // ...and every bot acted, not just the one that spoke. + expect(waitUntil( + [&] { + for (auto p : room.bandPhases()) + if (p == BandPlayState::State::Playing) + return false; + return !room.bandPhases().empty(); + }, + 8000), + "only the bot that spoke actually stopped"); + } + + beginTest("with the band half stopped, the one that acts speaks"); + { + // The mixed case, which the "same answer" rule alone gets wrong. Some + // bots wrap up and some say "already stopped" -- different sentences, + // but still one thing happening to one band. Whoever won a flat race + // would answer for everybody, and a silent bot winning would tell the + // room nothing was happening while the rest ended the tune. + PracticeRoom room; + auto cfg = testConfig("you"); + cfg.bpm = 240; + cfg.bpi = 4; // one second per interval, so an ending takes about two + expect(room.start(cfg)); + + Joiner you; + expect(you.join(room, "you")); + const auto keys = botPlaying(room, "keys"); + expect( + waitUntil([&] { return you.client.getRemoteUsers().count(keys) > 0; }, + 5000), + "the band never arrived"); + + expect(waitForRoster(you), "the band never introduced itself"); + expect(startBand(you, room), "the band would not start"); + + // Stop the bot that would WIN a flat race, so that a race is exactly + // what this catches. Picking any other one makes the test pass or fail + // on which names the seed happened to draw, which is no test at all. + std::vector band; + for (const auto &n : room.botNames()) + band.push_back(n.toStdString()); + + juce::String first; + int best = std::numeric_limits::max(); + for (const auto &n : room.botNames()) { + const int d = PracticeBot::speakDelayMs(n.toStdString(), band); + if (d < best) { + best = d; + first = n; + } + } + expect(first.isNotEmpty()); + + const auto handle = juce::String(BotNames::handleOf(first.toStdString())); + you.client.sendChatMessage(handle + ": stop"); + expect(waitUntil( + [&] { + int silent = 0, playing = 0; + for (auto p : room.bandPhases()) { + if (p == BandPlayState::State::Silent) + ++silent; + if (p == BandPlayState::State::Playing) + ++playing; + } + return silent >= 1 && playing >= 1; + }, + 10000), + "never reached a half-stopped band"); + + const int before = you.snapshot().size(); + you.client.sendChatMessage("band stop"); + juce::MessageManager::getInstance()->runDispatchLoopUntil(2500); + + juce::StringArray replies; + const auto all = you.snapshot(); + for (int i = before; i < all.size(); ++i) + if (all[i].startsWith("MSG|") && all[i].contains("-bot]")) + replies.add(all[i]); + + expectEquals(replies.size(), 1, + "a half-stopped band answered as a chorus: " + + replies.joinIntoString(" / ")); + if (replies.size() == 1) + expect(replies[0].containsIgnoreCase("wrapping"), + "a bot with nothing to do answered for the band: " + replies[0]); + } + + beginTest("each bot answers for itself when the answers differ"); + { + // The case the arbitration must NOT swallow. "band what are you playing" + // is four different facts and deserves four replies; collapsing it to + // one would lose three of them. + PracticeRoom room; + expect(room.start(testConfig("you"))); + + Joiner you; + expect(you.join(room, "you")); + expect(waitUntil( + [&] { + return you.client.getRemoteUsers().count( + botPlaying(room, "keys")) > 0; + }, + 5000), + "the band never arrived"); + + expect(waitForRoster(you), "the band never introduced itself"); + + const int before = you.snapshot().size(); + you.client.sendChatMessage("band what are you playing"); + juce::MessageManager::getInstance()->runDispatchLoopUntil(2500); + + juce::StringArray replies; + const auto all = you.snapshot(); + for (int i = before; i < all.size(); ++i) + if (all[i].startsWith("MSG|") && all[i].contains("-bot]")) + replies.add(all[i]); + + expect(replies.size() >= 3, + "the band gave one answer to a question with four: " + + replies.joinIntoString(" / ")); + } + + beginTest("the phase the renderer is given is the phase the bot is in"); + { + // The seam between the state machine and the sound, which nothing else + // reaches: `BandPlayState` decides WHEN a bot is ending and + // `BotBand::Phase` decides what that sounds like, and a bot that tracked + // its states perfectly while always rendering the groove would pass + // every other test in this file. + PracticeRoom room; + expect(room.start(testConfig("you"))); + + PracticeBot bot("Probe[kit-bot]", {"kit"}, + std::make_unique()); + expect(bot.join(PracticeRoom::host(), room.port(), 48000.0)); + bot.playAs(BotBand::Voice::Drums, keyOf("C major"), 120, 8, 48000.0, 7u); + bot.startPlaying(); // it joins silent, like every bot now does + + // Replaces the band's own render, which is the point: we care about the + // phase it is handed, not the audio it would have made from it. + std::vector seen; + bot.setRender([&seen](float *, float *, int, int, BotBand::Phase phase) { + seen.push_back(phase); + }); + + bot.renderInterval(4800, 0); + bot.stopPlaying(); + bot.renderInterval(4800, 1); + bot.renderInterval(4800, 2); + bot.renderInterval(4800, 3); + + // Three calls, not four: a silent bot does not render at all, let alone + // transmit an interval of zeroes. + expectEquals((int)seen.size(), 3, "a silent bot still rendered"); + if (seen.size() == 3) { + expect(seen[0] == BotBand::Phase::Groove, + "the tune was not the groove"); + expect(seen[1] == BotBand::Phase::Wrapping, "no wrap-up interval"); + expect(seen[2] == BotBand::Phase::Resolving, "no resolving interval"); + } + + bot.part(); + } + + beginTest("stopping ends the tune over two intervals, and does not leave"); + { + // The whole point of the four states, end to end over a real socket. A + // short interval so the ending is observable in about a second rather + // than twelve (docs/BOT-CHAT.md section 15). + PracticeRoom room; + auto cfg = testConfig("you"); + cfg.bpm = 240; + cfg.bpi = 4; // one second per interval + expect(room.start(cfg)); + + Joiner you; + expect(you.join(room, "you")); + const auto keys = botPlaying(room, "keys"); + expect( + waitUntil([&] { return you.client.getRemoteUsers().count(keys) > 0; }, + 5000), + "the band never arrived"); + + expect(startBand(you, room), "the band would not start"); + + auto everyoneIs = [&](BandPlayState::State want) { + const auto phases = room.bandPhases(); + if (phases.empty()) + return false; + for (auto p : phases) + if (p != want) + return false; + return true; + }; + expect(everyoneIs(BandPlayState::State::Playing), + "the band did not start out playing"); + + const auto handle = juce::String(BotNames::handleOf(keys.toStdString())); + you.client.sendChatMessage(handle + ": stop"); + + // Poll fast enough to see the ending happen rather than only its result: + // the states between playing and silence ARE the ending, and a bot that + // jumped straight to silence would have none. + bool sawEnding = false, sawSilent = false; + for (int i = 0; i < 400 && !sawSilent; ++i) { + const auto phases = room.bandPhases(); + for (auto p : phases) { + if (p == BandPlayState::State::Wrapping || + p == BandPlayState::State::Resolving) + sawEnding = true; + } + for (auto p : phases) + if (p == BandPlayState::State::Silent) + sawSilent = true; + juce::MessageManager::getInstance()->runDispatchLoopUntil(20); + } + + expect(sawEnding, "the bot went silent without playing an ending"); + expect(sawSilent, "the bot never stopped"); + + // Stopping is NOT leaving: it is still in the room, still a remote + // player, and can be asked to come back. + expect(room.botCount() > 0, "stopping sent the band home"); + expect(you.client.getRemoteUsers().count(keys) > 0, + "the bot left the room instead of stopping"); + + you.client.sendChatMessage(handle + ": play"); + expect(waitUntil( + [&] { + for (auto p : room.bandPhases()) + if (p == BandPlayState::State::Playing) + return true; + return false; + }, + 5000), + "the bot could not be brought back in"); + } + + beginTest( + "a bot told to be quiet stops answering, and can be brought back"); + { + PracticeRoom room; + expect(room.start(testConfig("you"))); + + Joiner you; + expect(you.join(room, "you")); + const auto keys = botPlaying(room, "keys"); + expect( + waitUntil([&] { return you.client.getRemoteUsers().count(keys) > 0; }, + 5000), + "the band never arrived"); + + const auto handle = juce::String(BotNames::handleOf(keys.toStdString())); + auto linesFrom = [&](const juce::String &who) { + int n = 0; + for (const auto &line : you.snapshot()) + if (juce::String(line).startsWith("MSG|" + who + "|")) + ++n; + return n; + }; + + you.client.sendChatMessage(handle + ": be quiet"); + expect(waitUntil([&] { return linesFrom(keys) > 0; }, 4000), + "going quiet was not acknowledged"); + const int afterHush = linesFrom(keys); + + // Directly addressed, and understood -- and still nothing, which is the + // whole of what was asked for. + you.client.sendChatMessage(handle + ": what key are we in"); + you.client.sendChatMessage(handle + ": whats your part"); + juce::MessageManager::getInstance()->runDispatchLoopUntil(1500); + expectEquals(linesFrom(keys), afterHush, "a quiet bot kept answering"); + + // And the way back, which is the only thing the acknowledgement said. + you.client.sendChatMessage(handle + ": talk"); + expect(waitUntil([&] { return linesFrom(keys) > afterHush; }, 4000), + "the bot could not be brought back"); + + // Only that bot went quiet: hushing one voice is not hushing the band. + const auto kit = botPlaying(room, "kit"); + const auto kitHandle = + juce::String(BotNames::handleOf(kit.toStdString())); + you.client.sendChatMessage(kitHandle + ": what key are we in"); + expect(waitUntil([&] { return linesFrom(kit) > 0; }, 4000), + "hushing one bot silenced another"); + } + + beginTest("bots do not answer each other"); + { + // The invariant that makes a feedback loop impossible rather than + // unlikely. A bot's own roster line names every other bot, so if this + // were wrong the room would fill in its opening second. + PracticeRoom room; + expect(room.start(testConfig("you"))); + + Joiner you; + expect(you.join(room, "you")); + const auto keys = botPlaying(room, "keys"); + expect( + waitUntil([&] { return you.client.getRemoteUsers().count(keys) > 0; }, + 5000), + "the band never arrived"); + + // Speak as a bot, naming another bot as plainly as possible. + const auto kit = botPlaying(room, "kit"); + room.practiceServer().broadcastChat( + kit, juce::String(BotNames::handleOf(keys.toStdString())) + + " what are you playing"); + juce::MessageManager::getInstance()->runDispatchLoopUntil(1500); + + juce::StringArray replies; + for (const auto &line : you.snapshot()) + if (juce::String(line).startsWith("MSG|") && + juce::String(line).contains("-bot]") && + !juce::String(line).contains("what are you playing")) + replies.add(line); + expect(replies.isEmpty(), + "a bot answered a bot: " + replies.joinIntoString(" / ")); + } + + beginTest("a bot follows a key announced in room chat"); + { + PracticeRoom room; + auto cfg = testConfig("you"); + cfg.key = MusicalKey::parseName("C major"); + expect(room.start(cfg)); + + Joiner you; + expect(you.join(room, "you")); + expect(waitUntil( + [&] { + return you.client.getRemoteUsers().count(botPlaying(room, "keys")) > + 0; + }, + 5000)); + + you.client.sendChatMessage("[key: D minor]"); + + // Observable through the room rather than by reaching into a bot: the + // chords the band is playing are what changed. + expect(waitUntil( + [&] { + for (const auto &s : room.bandSettings()) + if (s.key.tonic == 2 && Harmony::isMinorish(s.key.mode)) + return true; + return false; + }, + 5000), + "the band ignored the announced key"); + + for (const auto &s : room.bandSettings()) + expectEquals(Harmony::flatten(s.chart)[0].root, 2, + "the chords did not follow"); + } + + beginTest("a bot follows chords announced in room chat"); + { + PracticeRoom room; + expect(room.start(testConfig("you"))); + + Joiner you; + expect(you.join(room, "you")); + expect(waitUntil( + [&] { + return you.client.getRemoteUsers().count(botPlaying(room, "keys")) > + 0; + }, + 5000)); + + you.client.sendChatMessage("| Am | F | C | G |"); + + expect(waitUntil( + [&] { + for (const auto &s : room.bandSettings()) { + const auto chords = Harmony::flatten(s.chart); + if (chords.size() == 4 && chords[0].root == 9) + return true; + } + return false; + }, + 5000), + "the band ignored the announced chords"); + } + + beginTest( + "a key change moves a chart the room wrote rather than binning it"); + { + // The bug DESIGN.md section 6.4 exists to fix: announcing a key called + // `defaultChart` and threw away a progression somebody had typed. A + // player who writes a chart and then names the key has not withdrawn the + // chart -- they have said what it is relative to. + PracticeRoom room; + auto cfg = testConfig("you"); + cfg.key = MusicalKey::parseName("C major"); + expect(room.start(cfg)); + + Joiner you; + expect(you.join(room, "you")); + expect(waitUntil( + [&] { + return you.client.getRemoteUsers().count(botPlaying(room, "keys")) > + 0; + }, + 5000)); + + you.client.sendChatMessage("| Am | F | C | G |"); + expect(waitUntil( + [&] { + for (const auto &s : room.bandSettings()) { + const auto chords = Harmony::flatten(s.chart); + if (chords.size() == 4 && chords[0].root == 9) + return true; + } + return false; + }, + 5000), + "the band ignored the announced chords"); + + // A tonic move with the mode unchanged is pure transposition: vi IV I V + // in C is vi IV I V in D, two semitones up. + you.client.sendChatMessage("[key: D major]"); + expect(waitUntil( + [&] { + for (const auto &s : room.bandSettings()) + if (s.key.tonic == 2 && !Harmony::isMinorish(s.key.mode)) + return true; + return false; + }, + 5000), + "the band ignored the announced key"); + juce::MessageManager::getInstance()->runDispatchLoopUntil(500); + + for (const auto &s : room.bandSettings()) { + const auto chords = Harmony::flatten(s.chart); + expectEquals((int)chords.size(), 4, "the chart was replaced"); + const int wanted[] = {11, 7, 2, 9}; + for (int i = 0; i < juce::jmin(4, (int)chords.size()); ++i) + expectEquals(chords[(size_t)i].root, wanted[i], + "the chart did not travel with the key"); + } + } + + beginTest("a chart written in degrees reaches the band"); + { + // `parseDegreeChart` existed and nothing in the room called it, so + // "| ii | V | I |" was not a chart at all where the band could hear it. + PracticeRoom room; + auto cfg = testConfig("you"); + cfg.key = MusicalKey::parseName("C major"); + expect(room.start(cfg)); + + Joiner you; + expect(you.join(room, "you")); + expect(waitUntil( + [&] { + return you.client.getRemoteUsers().count(botPlaying(room, "keys")) > + 0; + }, + 5000)); + + you.client.sendChatMessage("| ii | V | I |"); + expect(waitUntil( + [&] { + for (const auto &s : room.bandSettings()) { + const auto chords = Harmony::flatten(s.chart); + if (chords.size() == 3 && chords[0].root == 2 && + chords[1].root == 7 && chords[2].root == 0) + return true; + } + return false; + }, + 5000), + "degrees did not reach the band"); + } + + beginTest("prose in chat does not become a progression"); + { + PracticeRoom room; + expect(room.start(testConfig("you"))); + + Joiner you; + expect(you.join(room, "you")); + expect(waitUntil( + [&] { + return you.client.getRemoteUsers().count(botPlaying(room, "keys")) > + 0; + }, + 5000)); + + const auto before = room.bandSettings(); + you.client.sendChatMessage("I AM TIRED OF THIS"); + you.client.sendChatMessage("anyone here?"); + juce::MessageManager::getInstance()->runDispatchLoopUntil(600); + + const auto after = room.bandSettings(); + expectEquals((int)after.size(), (int)before.size()); + for (size_t i = 0; i < after.size(); ++i) + expect(Harmony::flatten(after[i].chart) == + Harmony::flatten(before[i].chart), + "chat prose changed the harmony"); + } + + beginTest("the band follows a tempo change"); + { + PracticeRoom room; + expect(room.start(testConfig("you"))); + + Joiner you; + expect(you.join(room, "you")); + expect(waitUntil([&] { return room.botCount() == BotBand::kNumVoices; })); + + room.practiceServer().setConfig(96, 12); + + expect(waitUntil( + [&] { + for (const auto &s : room.bandSettings()) + if (s.bpm != 96 || s.bpi != 12) + return false; + return !room.bandSettings().empty(); + }, + 5000), + "the band did not follow the tempo"); + } + } + + void runConnectionLossTests() { + beginTest("a bot stops when the server goes, and does not come back"); + { + // Server exits, network drops, an admin kicks it: all the same path, and + // all terminal. A bot that reconnects is a bot nobody can get rid of. + PracticeServer server; + expect(server.start(120, 8)); + + PracticeBot bot("Mirn[kit-bot]", {"kit"}, + std::make_unique()); + expect(bot.join(PracticeRoom::host(), server.port(), 48000.0)); + expect(waitUntil([&] { return bot.client().isConnected(); }, 5000)); + expect(bot.isActive()); + + server.stop(); + + expect(waitUntil([&] { return !bot.isActive(); }, 5000), + "the bot stayed active after the server went"); + + // Give any reconnect logic every chance to exist and be caught. + juce::MessageManager::getInstance()->runDispatchLoopUntil(1000); + expect(!bot.isActive(), "the bot came back"); + expect(!bot.client().isConnected(), "the bot reconnected"); + } + + beginTest("part is idempotent and terminal"); + { + PracticeServer server; + expect(server.start(120, 8)); + + PracticeBot bot("Mirn[kit-bot]", {"kit"}, + std::make_unique()); + expect(bot.join(PracticeRoom::host(), server.port(), 48000.0)); + expect(waitUntil([&] { return bot.client().isConnected(); }, 5000)); + + bot.part(); + bot.part(); + expect(!bot.isActive()); + + // Rendering after parting must do nothing rather than crash or transmit. + bot.renderInterval(1024, 0); + expect(!bot.isActive()); + } + + beginTest("stopping a room removes the band from it"); + { + PracticeRoom room; + expect(room.start(testConfig("you"))); + Joiner you; + expect(you.join(room, "you")); + + const auto botName = room.botNames()[0]; + expect(waitUntil( + [&] { return you.client.getRemoteUsers().count(botName) > 0; }, + 5000)); + + room.stop(); + expectEquals(room.botCount(), 0); + } + } +}; + +static PracticeRoomTests practiceRoomTests; diff --git a/test/PracticeServerTests.cpp b/test/PracticeServerTests.cpp new file mode 100644 index 0000000..bb5d454 --- /dev/null +++ b/test/PracticeServerTests.cpp @@ -0,0 +1,406 @@ +#include "../src/NinjamClient.h" +#include "../src/PracticeServer.h" +#include "FakeNinjamServer.h" // for waitUntil +#include + +// PracticeServer is driven by real NinjamClients rather than by hand-built +// frames, because the thing being tested is that a room served from here is +// indistinguishable from a room on the network. A test that spoke the protocol +// itself could pass while the actual client saw nothing. + +namespace { + +struct Recording : public NinjamClientListener { + std::atomic connected{false}; + std::atomic userInfoChanges{0}; + juce::CriticalSection lock; + juce::Array chats; + int bpm = 0, bpi = 0; + + void onConnected() override { connected = true; } + void onDisconnected(const juce::String &) override { connected = false; } + void onServerConfig(int b, int i) override { + bpm = b; + bpi = i; + } + void onUserInfoChange() override { userInfoChanges.fetch_add(1); } + void onChatMessage(const juce::String &type, const juce::String &username, + const juce::String &text) override { + juce::ScopedLock sl(lock); + chats.add(type + "|" + username + "|" + text); + } + + juce::Array snapshot() const { + juce::ScopedLock sl(lock); + return chats; + } +}; + +// One client plus its listener, torn down in the order NinjamClient needs. +struct Member { + NinjamClient client; + Recording listener; + + Member() { client.addListener(&listener); } + ~Member() { + client.removeListener(&listener); + client.disconnectFromServer(); + } + + bool join(int port, const juce::String &name, double sr = 48000.0) { + client.setSampleRate(sr); + client.connectToServer("127.0.0.1", port, name, ""); + return waitUntil([this] { return client.isConnected(); }, 5000); + } +}; + +} // namespace + +class PracticeServerTests : public juce::UnitTest { +public: + PracticeServerTests() : juce::UnitTest("PracticeServer", "networking") {} + + void runTest() override { + runLifecycleTests(); + runRosterTests(); + runUsermaskTests(); + runChatTests(); + runConfigTests(); + } + + void runLifecycleTests() { + beginTest("binds a loopback port and reports it"); + { + PracticeServer server; + expect(server.start(120, 8)); + expect(server.port() > 0, "no port bound"); + expect(server.isListening()); + server.stop(); + expectEquals(server.port(), 0, "port should clear on stop"); + } + + beginTest("the room is reachable on 127.0.0.1 and nowhere else"); + { + PracticeServer server; + expect(server.start()); + + // Reachable on loopback. + Member a; + expect(a.join(server.port(), "alice"), "could not join over loopback"); + + // The safety property that replaces practice being offline: the listener + // is bound to the loopback interface only, so no other address on this + // machine can reach it. Anything routable would make a practice room + // visible to the network. + juce::StreamingSocket outside; + const auto ips = juce::IPAddress::getAllAddresses(); + for (const auto &ip : ips) { + if (ip.isNull() || ip.toString().startsWith("127.")) + continue; + expect(!outside.connect(ip.toString(), server.port(), 400), + "practice room answered on " + ip.toString()); + } + } + + beginTest("a second player joins the same room"); + { + PracticeServer server; + expect(server.start()); + Member a, b; + expect(a.join(server.port(), "alice")); + expect(b.join(server.port(), "bob")); + expect(waitUntil([&] { return server.clientCount() == 2; })); + + auto names = server.connectedUsernames(); + expect(names.contains("alice")); + expect(names.contains("bob")); + } + + beginTest("a duplicate name is made unique rather than shadowing"); + { + // Two players sharing a name would collide in NinjamClient's + // (username, channelIndex) slot key and mix into each other. + PracticeServer server; + expect(server.start()); + Member a, b; + expect(a.join(server.port(), "sam")); + expect(b.join(server.port(), "sam")); + expect(waitUntil([&] { return server.clientCount() == 2; })); + + auto names = server.connectedUsernames(); + expectEquals(names.size(), 2); + expect(names[0] != names[1], "duplicate names were not disambiguated"); + } + } + + void runRosterTests() { + beginTest("a joining player learns who is already in the room"); + { + PracticeServer server; + expect(server.start()); + + Member a; + expect(a.join(server.port(), "alice")); + a.client.updateChannelInfo({"gtr"}); + + // Bob arrives after alice has declared a channel, so he must be told + // about it on the way in rather than only on the next change. + Member b; + expect(b.join(server.port(), "bob")); + + expect(waitUntil([&] { + auto users = b.client.getRemoteUsers(); + auto it = users.find("alice"); + return it != users.end() && it->second.channels.count(0) > 0; + }), + "bob never saw alice's channel"); + + auto users = b.client.getRemoteUsers(); + expectEquals(users["alice"].channels[0].channelName, juce::String("gtr")); + } + + beginTest("a channel declared later reaches everyone already present"); + { + PracticeServer server; + expect(server.start()); + Member a, b; + expect(a.join(server.port(), "alice")); + expect(b.join(server.port(), "bob")); + expect(waitUntil([&] { return server.clientCount() == 2; })); + + a.client.updateChannelInfo({"gtr", "vox"}); + + expect(waitUntil([&] { + auto users = b.client.getRemoteUsers(); + auto it = users.find("alice"); + return it != users.end() && it->second.channels.size() == 2; + }), + "bob never saw alice's two channels"); + + auto users = b.client.getRemoteUsers(); + expectEquals(users["alice"].channels[1].channelName, juce::String("vox")); + } + + beginTest("a departing player's channels are retired"); + { + PracticeServer server; + expect(server.start()); + Member b; + expect(b.join(server.port(), "bob")); + + { + Member a; + expect(a.join(server.port(), "alice")); + a.client.updateChannelInfo({"gtr"}); + expect(waitUntil([&] { + return b.client.getRemoteUsers().count("alice") > 0; + }), + "bob never saw alice arrive"); + } + + expect(waitUntil( + [&] { return b.client.getRemoteUsers().count("alice") == 0; }), + "alice's channels outlived her connection"); + } + } + + void runUsermaskTests() { + beginTest("audio only reaches a subscriber"); + { + // The memory argument for the whole bot design: a client that has not + // subscribed receives nothing, so a deaf bot never causes an interval + // buffer to be allocated at the far end. + PracticeServer server; + expect(server.start(120, 8)); + + Member sender, listenerA, deaf; + expect(sender.join(server.port(), "sender")); + expect(listenerA.join(server.port(), "listener")); + expect(deaf.join(server.port(), "deaf")); + sender.client.updateChannelInfo({"gtr"}); + + expect(waitUntil([&] { + return listenerA.client.getRemoteUsers().count("sender") > 0 && + deaf.client.getRemoteUsers().count("sender") > 0; + }), + "the room never converged"); + + // NinjamClient subscribes to everyone it learns about; turning recv off + // is how a bot goes deaf, and it is the same public call a user makes + // with the Recv button. + deaf.client.setRemoteUserRecv("sender", 0, false); + juce::MessageManager::getInstance()->runDispatchLoopUntil(100); + + juce::AudioBuffer tone(2, 4096); + fillTone(tone, 440.0f, 48000.0); + sender.client.processCapturedAudio(tone, tone.getNumSamples(), 0, false); + + // Wait for the interval to be fully decoded before swapping. Swapping + // repeatedly would discard the very interval being waited for, which is + // what diagSamplesDroppedOnSwap counts. + expect(waitUntil( + [&] { + return listenerA.client.diagLastIntervalSamples.load() > 0; + }, + 5000), + "the subscriber never decoded an interval"); + expect(renderPeak(listenerA.client) > 0.0f, + "the subscriber decoded an interval but heard nothing"); + + // Give the unsubscribed client every chance to be wrong. + juce::MessageManager::getInstance()->runDispatchLoopUntil(500); + expectEquals(deaf.client.diagLastIntervalSamples.load(), 0, + "an unsubscribed client received audio"); + } + + beginTest("a sender never receives its own audio back"); + { + PracticeServer server; + expect(server.start()); + Member solo; + expect(solo.join(server.port(), "solo")); + solo.client.updateChannelInfo({"gtr"}); + + juce::AudioBuffer tone(2, 4096); + fillTone(tone, 440.0f, 48000.0); + solo.client.processCapturedAudio(tone, tone.getNumSamples(), 0, false); + + juce::MessageManager::getInstance()->runDispatchLoopUntil(500); + expectEquals(solo.client.diagLastIntervalSamples.load(), 0, + "the room echoed a player back to themselves"); + } + } + + void runChatTests() { + beginTest("chat reaches the room, attributed to the sender"); + { + PracticeServer server; + expect(server.start()); + Member a, b; + expect(a.join(server.port(), "alice")); + expect(b.join(server.port(), "bob")); + expect(waitUntil([&] { return server.clientCount() == 2; })); + + a.client.sendChatMessage("hello room"); + + expect(waitUntil([&] { + for (const auto &line : b.listener.snapshot()) + if (line == "MSG|alice|hello room") + return true; + return false; + }), + "bob never received alice's message"); + + // The sender sees their own message too, which is how the reference + // server behaves and what the chat pane expects. + expect(waitUntil([&] { + for (const auto &line : a.listener.snapshot()) + if (line == "MSG|alice|hello room") + return true; + return false; + }), + "alice never saw her own message"); + } + + beginTest("the server can speak into the room"); + { + PracticeServer server; + expect(server.start()); + Member a; + expect(a.join(server.port(), "alice")); + + server.broadcastChat("Mirn[kit-bot]", "counting you in"); + expect(waitUntil([&] { + for (const auto &line : a.listener.snapshot()) + if (line == "MSG|Mirn[kit-bot]|counting you in") + return true; + return false; + }), + "a server-originated line never arrived"); + } + + beginTest("a topic set before joining is delivered on arrival"); + { + PracticeServer server; + expect(server.start()); + server.setTopic("practice room"); + + Member a; + expect(a.join(server.port(), "alice")); + expect(waitUntil([&] { + for (const auto &line : a.listener.snapshot()) + if (line.startsWith("TOPIC|") && + line.endsWith("practice room")) + return true; + return false; + }), + "the topic was not sent to a joining player"); + } + } + + void runConfigTests() { + beginTest("tempo and BPI reach a joining player"); + { + PracticeServer server; + expect(server.start(96, 12)); + Member a; + expect(a.join(server.port(), "alice")); + expect(waitUntil([&] { return a.listener.bpm == 96; })); + expectEquals(a.listener.bpm, 96); + expectEquals(a.listener.bpi, 12); + } + + beginTest("a tempo change is broadcast to everyone"); + { + PracticeServer server; + expect(server.start(120, 8)); + Member a, b; + expect(a.join(server.port(), "alice")); + expect(b.join(server.port(), "bob")); + expect(waitUntil([&] { return server.clientCount() == 2; })); + + server.setConfig(140, 16); + expect(waitUntil([&] { + return a.listener.bpm == 140 && b.listener.bpm == 140; + }), + "the tempo change did not reach both players"); + expectEquals(a.listener.bpi, 16); + expectEquals(b.listener.bpi, 16); + expectEquals(server.bpm(), 140); + } + } + +private: + static void fillTone(juce::AudioBuffer &buf, float freq, + double sampleRate) { + for (int ch = 0; ch < buf.getNumChannels(); ++ch) { + auto *w = buf.getWritePointer(ch); + for (int i = 0; i < buf.getNumSamples(); ++i) + w[i] = 0.5f * std::sin(2.0f * juce::MathConstants::pi * freq * + (float)i / (float)sampleRate); + } + } + + // Vorbis is lossy and has codec delay, so the question is only ever "was + // there energy", never "were these samples equal" (AGENTS.md). + // + // Swaps exactly once: each swap retires whatever the audio thread has not + // consumed, so swapping in a loop throws away the interval being measured. + static float renderPeak(NinjamClient &client, int numSamples = 32768) { + client.swapIntervalBuffers(); + + const int blockSize = 512; + juce::AudioBuffer block(2, blockSize); + float peak = 0.0f; + for (int pos = 0; pos < numSamples; pos += blockSize) { + const int n = std::min(blockSize, numSamples - pos); + block.clear(); + juce::AudioBuffer view(block.getArrayOfWritePointers(), 2, n); + client.getDecodedAudio(view); + peak = std::max(peak, view.getMagnitude(0, n)); + } + return peak; + } +}; + +static PracticeServerTests practiceServerTests; diff --git a/test/ReferenceFixtureTests.cpp b/test/ReferenceFixtureTests.cpp index 2982aee..1dcb21b 100644 --- a/test/ReferenceFixtureTests.cpp +++ b/test/ReferenceFixtureTests.cpp @@ -40,12 +40,13 @@ juce::File fixtureDir() { return {}; } -juce::MemoryBlock loadFixture(const juce::String &name) { +ByteBuffer loadFixture(const juce::String &name) { juce::MemoryBlock mb; const auto dir = fixtureDir(); if (dir.isDirectory()) dir.getChildFile(name).loadFileAsData(mb); - return mb; + const auto *p = static_cast(mb.getData()); + return ByteBuffer(p, p + mb.getSize()); } class ReferenceFixtureTests : public juce::UnitTest { @@ -75,7 +76,7 @@ class ReferenceFixtureTests : public juce::UnitTest { void testAuthPacket() { beginTest("reference CLIENT_AUTH_USER parses and round-trips"); auto raw = loadFixture("80_client_auth_user.bin"); - if (raw.getSize() == 0) { + if (raw.size() == 0) { logMessage("fixture missing -- skipping"); expect(true); return; @@ -83,11 +84,11 @@ class ReferenceFixtureTests : public juce::UnitTest { // Layout: 20-byte hash + NUL-terminated username + 4-byte caps + 4-byte // version, both little-endian. - expect(raw.getSize() > 29, "auth packet implausibly short"); + expect(raw.size() > 29, "auth packet implausibly short"); - NinjamProtocol::Reader r(raw.getData(), raw.getSize()); + NinjamProtocol::Reader r(raw.data(), raw.size()); juce::uint8 hash[20]; - juce::String username; + std::string username; juce::uint32 caps = 0, version = 0; expect(r.bytes(hash, 20)); expect(r.cstr(username)); @@ -96,17 +97,17 @@ class ReferenceFixtureTests : public juce::UnitTest { expect(r.ok() && r.atEnd(), "auth packet had trailing bytes we do not account for"); - logMessage("reference auth: user '" + username + "', caps " + + logMessage("reference auth: user '" + juce::String(username) + "', caps " + juce::String((int)caps) + ", version 0x" + juce::String::toHexString((int)version)); - expect(username.isNotEmpty(), "no username in the reference auth packet"); + expect(!username.empty(), "no username in the reference auth packet"); expectEquals((int)version, 0x00020000, "protocol version differs from the reference client"); // Our builder must produce a byte-identical packet from the same inputs. auto ours = NinjamProtocol::buildAuthUser(hash, username, caps, version); - expectEquals((int)ours.getSize(), (int)raw.getSize()); + expectEquals((int)ours.size(), (int)raw.size()); expect(ours == raw, "our CLIENT_AUTH_USER differs byte-for-byte from the reference"); } @@ -118,34 +119,39 @@ class ReferenceFixtureTests : public juce::UnitTest { void testChannelInfoPacket() { beginTest("reference CLIENT_SET_CHANNEL_INFO layout matches ours"); auto raw = loadFixture("82_client_set_channel_info.bin"); - if (raw.getSize() == 0) { + if (raw.size() == 0) { logMessage("fixture missing -- skipping"); expect(true); return; } - const auto *b = static_cast(raw.getData()); - expect(raw.getSize() >= 2, "channel info too short"); + const auto *b = static_cast(raw.data()); + expect(raw.size() >= 2, "channel info too short"); const int mpisize = (int)b[0] | ((int)b[1] << 8); logMessage("reference mpisize = " + juce::String(mpisize)); expectEquals(mpisize, 4, "reference uses a different per-channel metadata size"); // Read the channel names the reference declared. - NinjamProtocol::Reader r(raw.getData(), raw.getSize()); + NinjamProtocol::Reader r(raw.data(), raw.size()); juce::uint16 msz; expect(r.u16le(msz)); - juce::StringArray names; + std::vector names; while (!r.atEnd()) { - juce::String name; + std::string name; if (!r.cstr(name)) break; if (!r.skip((size_t)msz)) break; - names.add(name); + names.push_back(name); } expect(names.size() >= 1, "no channel names in the reference packet"); - logMessage("reference channels: " + names.joinIntoString(", ")); + { + juce::StringArray forLog; + for (const auto &n : names) + forLog.add(juce::String(n)); + logMessage("reference channels: " + forLog.joinIntoString(", ")); + } // Our builder must agree for the same channel list. auto ours = NinjamProtocol::buildChannelInfo(names); @@ -160,19 +166,19 @@ class ReferenceFixtureTests : public juce::UnitTest { void testUploadBeginPacket() { beginTest("reference UPLOAD_INTERVAL_BEGIN is 25 bytes of OGGv"); auto raw = loadFixture("83_upload_interval_begin.bin"); - if (raw.getSize() == 0) { + if (raw.size() == 0) { logMessage("fixture missing -- skipping"); expect(true); return; } - expectEquals((int)raw.getSize(), 25, + expectEquals((int)raw.size(), 25, "the reference client's 0x83 is not 25 bytes"); NinjamProtocol::IntervalBegin begin; expect(NinjamProtocol::parseIntervalBegin(raw, begin)); expect(begin.isOggAudio(), "reference fourCC is not OGGv"); - expect(begin.username.isEmpty(), "0x83 must carry no username"); + expect(begin.username.empty(), "0x83 must carry no username"); logMessage("reference upload begin: channel " + juce::String(begin.channelIndex) + ", estsize " + juce::String((int)begin.estimatedSize)); @@ -186,16 +192,16 @@ class ReferenceFixtureTests : public juce::UnitTest { void testUsermaskPacket() { beginTest("reference CLIENT_SET_USERMASK layout matches ours"); auto raw = loadFixture("81_client_set_usermask.bin"); - if (raw.getSize() == 0) { + if (raw.size() == 0) { logMessage("fixture missing -- skipping"); expect(true); return; } - NinjamProtocol::Reader r(raw.getData(), raw.getSize()); - std::vector> masks; + NinjamProtocol::Reader r(raw.data(), raw.size()); + std::vector> masks; while (!r.atEnd()) { - juce::String name; + std::string name; juce::uint32 mask = 0; if (!r.cstr(name) || !r.u32le(mask)) break; @@ -203,8 +209,8 @@ class ReferenceFixtureTests : public juce::UnitTest { } expect(!masks.empty(), "no entries in the reference usermask"); for (const auto &[name, mask] : masks) - logMessage("reference subscribes to '" + name + "' mask 0x" + - juce::String::toHexString((int)mask)); + logMessage("reference subscribes to '" + juce::String(name) + + "' mask 0x" + juce::String::toHexString((int)mask)); auto ours = NinjamProtocol::buildUsermask(masks); expect(ours == raw, "our CLIENT_SET_USERMASK differs from the reference"); @@ -216,7 +222,7 @@ class ReferenceFixtureTests : public juce::UnitTest { void testReferenceOggDecodes() { beginTest("reference Ogg stream decodes to the expected audio"); auto raw = loadFixture("reference_interval_48000.ogg"); - if (raw.getSize() == 0) { + if (raw.size() == 0) { logMessage("fixture missing -- skipping"); expect(true); return; @@ -224,9 +230,9 @@ class ReferenceFixtureTests : public juce::UnitTest { VorbisDecoder dec; std::vector pcm; - const auto *bytes = static_cast(raw.getData()); - for (size_t pos = 0; pos < raw.getSize(); pos += 4096) { - const int n = (int)std::min((size_t)4096, raw.getSize() - pos); + const auto *bytes = static_cast(raw.data()); + for (size_t pos = 0; pos < raw.size(); pos += 4096) { + const int n = (int)std::min((size_t)4096, raw.size() - pos); dec.decode(bytes + pos, n); while (dec.available() > 0) { const int avail = dec.available(); diff --git a/test/Sha1Tests.cpp b/test/Sha1Tests.cpp deleted file mode 100644 index 91dff46..0000000 --- a/test/Sha1Tests.cpp +++ /dev/null @@ -1,86 +0,0 @@ -#include - -#include "Sha1.h" - -#include - -namespace { - -juce::String toHex(const uint8_t digest[20]) { - juce::String s; - for (int i = 0; i < 20; ++i) - s += juce::String::toHexString((int)digest[i]).paddedLeft('0', 2); - return s; -} - -juce::String hashOf(const std::string &input) { - Sha1 sha; - sha.add(input.data(), (int)input.size()); - uint8_t digest[20]; - sha.result(digest); - return toHex(digest); -} - -class Sha1Tests : public juce::UnitTest { -public: - Sha1Tests() : juce::UnitTest("Sha1", "Sha1") {} - - void runTest() override { - beginTest("FIPS 180-1 vectors"); - expectEquals(hashOf("abc"), - juce::String("a9993e364706816aba3e25717850c26c9cd0d89d")); - expectEquals( - hashOf("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"), - juce::String("84983e441c3bd26ebaae4aa1f95129e5e54670f1")); - expectEquals(hashOf(std::string(1000000, 'a')), - juce::String("34aa973cd4c4daa4f61eeb2bdbad27316534016f")); - - beginTest("empty input"); - expectEquals(hashOf(""), - juce::String("da39a3ee5e6b4b0d3255bfef95601890afd80709")); - - beginTest("incremental add equals monolithic"); - // The auth path feeds SHA1 in several add() calls, so this invariant is - // load-bearing. Exercise every split point, including across the internal - // 64-byte block boundary. - const std::string msg = - "the quick brown fox jumps over the lazy dog, repeatedly, until this " - "string is comfortably longer than one sha1 block of sixty-four bytes"; - const juce::String whole = hashOf(msg); - for (size_t split = 0; split <= msg.size(); ++split) { - Sha1 sha; - sha.add(msg.data(), (int)split); - sha.add(msg.data() + split, (int)(msg.size() - split)); - uint8_t digest[20]; - sha.result(digest); - if (toHex(digest) != whole) { - expect(false, "split at " + juce::String((int)split) + " differs"); - break; - } - } - expect(true); - - beginTest("result() resets state for reuse"); - Sha1 sha; - sha.add("abc", 3); - uint8_t first[20]; - sha.result(first); - sha.add("abc", 3); - uint8_t second[20]; - sha.result(second); - expectEquals(toHex(second), toHex(first)); - - beginTest("zero-length add is a no-op"); - Sha1 a; - a.add("abc", 3); - a.add("", 0); - uint8_t d[20]; - a.result(d); - expectEquals(toHex(d), - juce::String("a9993e364706816aba3e25717850c26c9cd0d89d")); - } -}; - -static Sha1Tests sha1Tests; - -} // namespace diff --git a/test/SharedContractTests.cpp b/test/SharedContractTests.cpp new file mode 100644 index 0000000..51359b6 --- /dev/null +++ b/test/SharedContractTests.cpp @@ -0,0 +1,94 @@ +#include +#include + +#include + +// SharedContractTests -- the properties that must survive extraction into the +// shared Chalkwalk libraries. +// +// The counterpart of the same suite in the other consumer. The Euclidean table +// below is byte-identical to the one there, deliberately: two repositories, one +// table. If the implementations ever drift apart, one of the two suites goes +// red. That is the closest thing to a shared test available before there is a +// shared repository, and it is why the duplication is the point rather than an +// oversight. +// +// When this code moves to chalkwalk-music and chalkwalk-dsp, THIS FILE MOVES +// WITH IT and must still pass unchanged. Do not relax an expectation here to +// make a port compile. + +class SharedContractTests : public juce::UnitTest { +public: + SharedContractTests() : juce::UnitTest("SharedContract", "ecosystem") {} + + void runTest() override { + runPolyBlepSign(); + runHermite(); + runSvfStability(); + } + +private: + // -------------------------------------------------------------------- + // polyBLEP: the sign, which is the whole reason this code was worth + // sharing. It was inverted at its origin for the life of that project and + // found within hours of being retyped here; it has since been fixed there. + // Both are correct now, and this states the property that an inverted sign + // breaks, in a form both repositories can assert identically. + // -------------------------------------------------------------------- + void runPolyBlepSign() { + beginTest("polyBLEP shrinks the step it corrects rather than enlarging it"); + const double inc = 5000.0 / 48000.0; + + const float justBefore = BotDsp::polyBlepSaw(1.0 - inc * 0.5, inc); + const float justAfter = BotDsp::polyBlepSaw(inc * 0.5, inc); + const float naiveBefore = (float)((2.0 * (1.0 - inc * 0.5)) - 1.0); + const float naiveAfter = (float)((2.0 * (inc * 0.5)) - 1.0); + + expect(std::abs(justBefore - justAfter) < + std::abs(naiveBefore - naiveAfter), + "if this fails the polyBLEP sign is inverted"); + + beginTest("polyBLEP is inert away from the edge"); + for (double phase = 0.2; phase < 0.8; phase += 0.05) + expect(std::abs(BotDsp::polyBlepSaw(phase, 0.01) - + (float)((2.0 * phase) - 1.0)) < 1.0e-6f); + + beginTest("a zero or negative increment is inert"); + expectEquals(BotDsp::polyBlep(0.5, 0.0), 0.0f); + expectEquals(BotDsp::polyBlep(0.5, -1.0), 0.0f); + } + + void runHermite() { + beginTest("hermite4 is exact on a straight line"); + for (double t = 0.0; t <= 1.0; t += 0.05) + expect(std::abs(BotDsp::hermite4(0.0f, 1.0f, 2.0f, 3.0f, (float)t) - + (1.0f + (float)t)) < 1.0e-5f); + + beginTest("hermite4 passes through its samples"); + expect(std::abs(BotDsp::hermite4(3.0f, 7.0f, 11.0f, 2.0f, 0.0f) - 7.0f) < + 1.0e-5f); + expect(std::abs(BotDsp::hermite4(3.0f, 7.0f, 11.0f, 2.0f, 1.0f) - 11.0f) < + 1.0e-5f); + } + + void runSvfStability() { + beginTest("the filter stays bounded everywhere it will be driven"); + for (float cutoff : {20.0f, 100.0f, 1000.0f, 10000.0f, 20000.0f}) + for (float q : {0.3f, 0.707f, 4.0f, 20.0f}) + for (int mode = 0; mode < 4; ++mode) { + BotDsp::Svf f; + f.set(cutoff, q, 48000.0); + float worst = 0.0f; + for (int i = 0; i < 4096; ++i) { + const float in = (i % 2 == 0) ? 1.0f : -1.0f; // Nyquist + worst = std::max(worst, + std::abs(f.process(in, (BotDsp::Svf::Mode)mode))); + } + expect(std::isfinite(worst) && worst < 100.0f, + "Svf bounded at cutoff " + juce::String(cutoff) + " q " + + juce::String(q) + " mode " + juce::String(mode)); + } + } +}; + +static SharedContractTests sharedContractTests; diff --git a/test/SpscRingTests.cpp b/test/SpscRingTests.cpp deleted file mode 100644 index 76710c6..0000000 --- a/test/SpscRingTests.cpp +++ /dev/null @@ -1,131 +0,0 @@ -#include - -#include "SpscRing.h" - -#include -#include - -namespace { - -class SpscRingTests : public juce::UnitTest { -public: - SpscRingTests() : juce::UnitTest("SpscRing", "SpscRing") {} - - void runTest() override { - beginTest("what goes in comes out, in order"); - { - SpscRing ring; - int values[5] = {1, 2, 3, 4, 5}; - for (auto &v : values) - expect(ring.push(&v)); - for (auto &v : values) - expectEquals(*ring.pop(), v); - expect(ring.pop() == nullptr, "and then it is empty"); - } - - beginTest("an empty ring returns null rather than blocking"); - { - SpscRing ring; - expect(ring.isEmpty()); - expect(ring.pop() == nullptr); - } - - beginTest("a full ring refuses rather than blocking"); - { - // The audio path has to be able to keep going when the ring is full, so - // push reports failure instead of waiting for room. - SpscRing ring; - int v = 7; - for (int i = 0; i < 4; ++i) - expect(ring.push(&v), "capacity 4 must accept 4"); - expect(!ring.push(&v), "and refuse the fifth"); - expectEquals(ring.sizeApprox(), 4); - } - - beginTest("capacity really is the stated capacity"); - { - // The spare slot that distinguishes full from empty is an implementation - // detail and must not cost the caller an entry. - SpscRing ring; - int v = 0; - expectEquals(ring.capacity(), 3); - for (int i = 0; i < 3; ++i) - expect(ring.push(&v)); - expect(!ring.push(&v)); - } - - beginTest("it wraps"); - { - SpscRing ring; - std::vector vals(64); - for (int i = 0; i < 64; ++i) - vals[(size_t)i] = i; - // Far more traffic than the ring holds, one in one out, so the indices - // wrap many times. - for (int i = 0; i < 64; ++i) { - expect(ring.push(&vals[(size_t)i]), "push " + juce::String(i)); - expectEquals(*ring.pop(), i); - } - expect(ring.isEmpty()); - } - - beginTest("popping frees the slot for reuse"); - { - SpscRing ring; - int a = 1, b = 2, c = 3; - expect(ring.push(&a)); - expect(ring.push(&b)); - expect(!ring.push(&c), "full"); - expectEquals(*ring.pop(), 1); - expect(ring.push(&c), "a pop must make room"); - expectEquals(*ring.pop(), 2); - expectEquals(*ring.pop(), 3); - } - - beginTest("a producer and a consumer on separate threads lose nothing"); - { - // The property that matters: under real concurrency every pointer that is - // accepted comes out exactly once, in order. Run under TSan to also check - // the memory ordering -- this test passing under a normal build says - // nothing about that. - constexpr int kCount = 20000; - SpscRing ring; - std::vector source((size_t)kCount); - for (int i = 0; i < kCount; ++i) - source[(size_t)i] = i; - - std::atomic producerDone{false}; - std::vector received; - received.reserve((size_t)kCount); - - std::thread producer([&] { - for (int i = 0; i < kCount;) { - if (ring.push(&source[(size_t)i])) - ++i; // only advance when it was accepted - else - std::this_thread::yield(); - } - producerDone.store(true); - }); - - while (!producerDone.load() || !ring.isEmpty()) { - if (int *v = ring.pop()) - received.push_back(*v); - } - producer.join(); - - expectEquals((int)received.size(), kCount, "nothing was dropped"); - bool ordered = true; - for (int i = 0; i < (int)received.size(); ++i) - if (received[(size_t)i] != i) { - ordered = false; - break; - } - expect(ordered, "and nothing was reordered or duplicated"); - } - } -}; - -static SpscRingTests spscRingTests; - -} // namespace diff --git a/test/VorbisCodecTests.cpp b/test/VorbisCodecTests.cpp deleted file mode 100644 index 8f949c5..0000000 --- a/test/VorbisCodecTests.cpp +++ /dev/null @@ -1,272 +0,0 @@ -#include - -#include "TestSignal.h" -#include "VorbisCodec.h" - -#include -#include - -namespace { - -// Encodes interleaved frames and returns the complete Ogg stream, including the -// end-of-stream flush. -std::vector encodeAll(const float *interleaved, int numFrames, - int sampleRate, int numChannels, - int bitrateKbps = 128) { - VorbisEncoder enc(sampleRate, numChannels, bitrateKbps, 12345); - std::vector out; - - auto drain = [&]() { - while (enc.available() > 0) { - const int n = enc.available(); - const auto *p = static_cast(enc.data()); - out.insert(out.end(), p, p + n); - enc.advance(n); - } - }; - - drain(); // the constructor emits the three header pages eagerly - - const int block = 1024; - for (int pos = 0; pos < numFrames; pos += block) { - const int n = std::min(block, numFrames - pos); - enc.encode(interleaved + (size_t)pos * numChannels, n); - drain(); - } - - enc.encode(nullptr, 0); - drain(); - return out; -} - -struct Decoded { - std::vector interleaved; - int sampleRate = 0; - int numChannels = 0; - int numFrames() const { - return numChannels > 0 ? (int)interleaved.size() / numChannels : 0; - } -}; - -Decoded decodeAll(const std::vector &bytes, int chunkSize = 4096) { - VorbisDecoder dec; - Decoded d; - - for (size_t pos = 0; pos < bytes.size(); pos += (size_t)chunkSize) { - const int n = (int)std::min((size_t)chunkSize, bytes.size() - pos); - dec.decode(bytes.data() + pos, n); - while (dec.available() > 0) { - const int avail = dec.available(); - const float *p = dec.pcm(); - d.interleaved.insert(d.interleaved.end(), p, p + avail); - dec.skip(avail); - } - } - - d.sampleRate = dec.sampleRate(); - d.numChannels = dec.numChannels(); - return d; -} - -class VorbisCodecTests : public juce::UnitTest { -public: - VorbisCodecTests() : juce::UnitTest("VorbisCodec", "VorbisCodec") {} - - void runTest() override { - beginTest("encoder honours its constructed sample rate"); - // The unit-level companion to the NinjamClient TX bug: a stream encoded at - // rate R must declare rate R, or every listener resamples it wrongly. - for (int sr : {44100, 48000, 88200, 96000}) { - auto pcm = TestSignal::makeSine(sr / 4, 2, 440.0, (double)sr, 0.5f); - auto bytes = encodeAll(pcm.data(), sr / 4, sr, 2); - auto d = decodeAll(bytes); - expectEquals(d.sampleRate, sr, - "declared rate wrong for encoder at " + juce::String(sr)); - expectEquals(d.numChannels, 2); - } - - beginTest("stereo round-trip preserves level and pitch"); - { - const int sr = 48000, frames = sr; // one second - auto pcm = TestSignal::makeSine(frames, 2, 440.0, sr, 0.5f); - auto d = decodeAll(encodeAll(pcm.data(), frames, sr, 2)); - - expect(d.numFrames() > frames / 2, "decoder returned too little audio"); - - // Skip the first and last 10% to avoid codec ramp-in/out. - const int skip = d.numFrames() / 10; - const int n = d.numFrames() - 2 * skip; - const float *left = d.interleaved.data() + (size_t)skip * 2; - - const double inRms = TestSignal::rms(pcm.data(), frames, 2); - const double outRms = TestSignal::rms(left, n, 2); - const double deltaDb = TestSignal::toDb(outRms) - TestSignal::toDb(inRms); - expect(std::fabs(deltaDb) < 1.0, - "level moved by " + juce::String(deltaDb, 2) + " dB"); - - const double freq = TestSignal::dominantFrequency(left, n, sr, 2); - expect(std::fabs(freq - 440.0) / 440.0 < 0.02, - "measured " + juce::String(freq, 1) + " Hz, expected 440"); - } - - beginTest("decoded frame count is close to encoded"); - { - const int sr = 48000, frames = 24000; - auto pcm = TestSignal::makeSine(frames, 2, 440.0, sr, 0.5f); - auto d = decodeAll(encodeAll(pcm.data(), frames, sr, 2)); - const double ratio = (double)d.numFrames() / (double)frames; - expect(ratio > 0.98 && ratio < 1.02, - "got " + juce::String(d.numFrames()) + " of " + - juce::String(frames) + " frames"); - } - - beginTest("mono round-trip"); - { - const int sr = 48000, frames = 24000; - auto pcm = TestSignal::makeSine(frames, 1, 440.0, sr, 0.5f); - auto d = decodeAll(encodeAll(pcm.data(), frames, sr, 1)); - expectEquals(d.numChannels, 1); - expectEquals(d.sampleRate, sr); - - const int skip = d.numFrames() / 10; - const int n = d.numFrames() - 2 * skip; - const double freq = - TestSignal::dominantFrequency(d.interleaved.data() + skip, n, sr, 1); - expect(std::fabs(freq - 440.0) / 440.0 < 0.02, - "measured " + juce::String(freq, 1) + " Hz"); - } - - beginTest("truncated multi-page stream yields partial audio"); - { - // Noise is incompressible, so ten seconds of it spans many Ogg pages and - // a truncated prefix decodes to roughly the corresponding fraction. - const int sr = 48000, frames = sr * 10; - juce::Random rng(7); - std::vector pcm((size_t)frames * 2); - for (auto &v : pcm) - v = (float)(rng.nextDouble() * 2.0 - 1.0) * 0.5f; - - auto bytes = encodeAll(pcm.data(), frames, sr, 2); - auto full = decodeAll(bytes); - expectEquals(full.numFrames(), frames); - - auto truncated = bytes; - truncated.resize(truncated.size() / 2); - auto d = decodeAll(truncated); - const double fraction = (double)d.numFrames() / (double)frames; - expect(fraction > 0.3 && fraction < 0.7, - "half a stream decoded to " + juce::String(fraction * 100.0, 1) + - "% of the audio"); - } - - beginTest("short compressible stream is a single page (all-or-nothing)"); - { - // Load-bearing property of interval delivery, so it is pinned rather than - // assumed. ogg_stream_pageout only emits a page once roughly 4 kB has - // accumulated, so a quiet or tonal interval produces NO decodable audio - // until the end-of-stream flush. A receiver therefore cannot start - // playing an interval early just because some WRITE chunks have arrived; - // it must wait for the final chunk. If this test ever starts failing, the - // paging behaviour changed and the interval buffering assumptions in - // NinjamClient need revisiting. - const int sr = 48000, frames = sr; // one second of pure tone - auto pcm = TestSignal::makeSine(frames, 2, 440.0, sr, 0.5f); - auto bytes = encodeAll(pcm.data(), frames, sr, 2); - - auto truncated = bytes; - truncated.resize(truncated.size() * 99 / 100); - auto d = decodeAll(truncated); - expectEquals(d.numFrames(), 0, "expected no audio before the final page"); - - expectEquals(decodeAll(bytes).numFrames(), frames); - } - - beginTest("garbage input does not crash or produce audio"); - { - juce::Random rng(42); - std::vector junk(4096); - for (auto &b : junk) - b = (uint8_t)rng.nextInt(256); - auto d = decodeAll(junk); - expectEquals(d.numFrames(), 0); - } - - beginTest("header-only stream produces no audio"); - { - VorbisEncoder enc(48000, 2, 128, 1); - std::vector headers; - while (enc.available() > 0) { - const int n = enc.available(); - const auto *p = static_cast(enc.data()); - headers.insert(headers.end(), p, p + n); - enc.advance(n); - } - expect(!headers.empty(), "constructor emitted no header pages"); - auto d = decodeAll(headers); - expectEquals(d.sampleRate, 48000); - expectEquals(d.numChannels, 2); - expectEquals(d.numFrames(), 0); - } - - beginTest("interval timing probe survives the codec"); - { - // Timing markers are only useful if they come back where they went in. - // A single-sample impulse does not survive a perceptual codec, so the - // probe uses short enveloped tone bursts instead. This pins that they - // are recoverable, and located to within a millisecond, after a real - // encode/decode round trip -- the property the interop timing tests and - // the archive analysis both depend on. - const int sr = 48000; - const int intervalLen = sr * 2; // 2 s "interval" - TestSignal::IntervalProbe probe; - - std::vector pcm((size_t)intervalLen * 2); - for (int i = 0; i < intervalLen; ++i) { - const float v = probe.sampleAt(i, intervalLen, i, sr); - pcm[(size_t)i * 2] = v; - pcm[(size_t)i * 2 + 1] = v; - } - - auto d = decodeAll(encodeAll(pcm.data(), intervalLen, sr, 2)); - expect(d.numFrames() > intervalLen / 2, "codec returned too little"); - - std::vector left((size_t)d.numFrames()); - for (int i = 0; i < d.numFrames(); ++i) - left[(size_t)i] = d.interleaved[(size_t)i * 2]; - - auto found = TestSignal::findBursts( - left.data(), (int)left.size(), probe.burstHz, probe.burstSeconds, sr); - expectEquals((int)found.size(), (int)probe.positions.size(), - "expected one detected burst per probe position"); - - if (found.size() == probe.positions.size()) { - const double tolerance = 0.001 * sr; // 1 ms - for (size_t i = 0; i < found.size(); ++i) { - const int expectedAt = - (int)(probe.positions[i] * (double)intervalLen); - expect(std::abs(found[i] - expectedAt) < tolerance, - "burst " + juce::String((int)i) + " found at " + - juce::String(found[i]) + ", expected near " + - juce::String(expectedAt)); - } - } - } - - beginTest("decoder tolerates single-byte feeding"); - { - const int sr = 48000, frames = 4800; - auto pcm = TestSignal::makeSine(frames, 2, 440.0, sr, 0.5f); - auto bytes = encodeAll(pcm.data(), frames, sr, 2); - auto d = decodeAll(bytes, 1); - expectEquals(d.sampleRate, sr); - const double ratio = (double)d.numFrames() / (double)frames; - expect(ratio > 0.98 && ratio < 1.02, "byte-at-a-time decode gave " + - juce::String(d.numFrames()) + - " frames"); - } - } -}; - -static VorbisCodecTests vorbisCodecTests; - -} // namespace diff --git a/tools/BandLabMain.cpp b/tools/BandLabMain.cpp new file mode 100644 index 0000000..4127564 --- /dev/null +++ b/tools/BandLabMain.cpp @@ -0,0 +1,830 @@ +// antiphon-bandlab: turn every knob in the band, and hear it. +// +// The voice lab renders a WAV and prints numbers, which is the right tool for +// establishing a fact and the wrong one for finding a sound. Finding a sound is +// dozens of small moves with a listen after each, and a loop of edit-a-constant +// / rebuild / render / open is far too slow to converge -- so it did not +// converge. It went through me instead, one adjustment per message, and neither +// of us can hear what the other is talking about. +// +// This is the same renderer with a control surface on it. Every parameter is a +// slider, the band loops while you move them, and it re-renders in the +// background so a change is audible within about a second. +// +// TWO THINGS PER CONTROL, and the second is the one that matters. A slider sets +// the VALUE, and two boxes beside it set the RANGE -- the span a seed is +// allowed to pick inside. The value is what sounded right today; the range is +// the claim about the instrument, and it is the thing that can only be +// established by listening to both of its ends. `Save` writes both. +// +// A development instrument, not a shipped one: built, never installed, and +// listed in ROADMAP.md as something to retire once the voices settle. It links +// the band's own sources, so what it plays is what the room plays -- the same +// figures, the same harmony, the same interval wrapping. + +#include + +#include "AudioMeasure.h" +#include +#include +#include "MusicalKey.h" + +namespace { + +constexpr double kSampleRate = 48000.0; +constexpr int kBars = 4; + +juce::String twoDigits(double v) { + // Frequencies want no decimals and mix levels want three, and one format for + // both reads badly. Scale decides. + const double a = std::abs(v); + if (a >= 1000.0) + return juce::String(v, 0); + if (a >= 10.0) + return juce::String(v, 2); + return juce::String(v, 4); +} + +// --------------------------------------------------------------------------- + +// One control: a name, a slider, its two limits, and three buttons for the +// bottom, middle and top of them. +// +// The buttons exist because that is how a range is actually judged. You do not +// decide "9 to 16 cents" by sweeping a slider; you listen to 9, listen to 16, +// and ask whether both of them are still the instrument. One click each. +// A fader that spans MORE than the bot's range, and shows the difference. +// +// The fader used to span exactly lo..hi, which meant it could never be moved +// outside them -- so shift-click could only ever shrink a range and widening +// one meant typing. The fader now spans the shipped range widened by half its +// width at each end, and the part the seed may actually reach is drawn inside +// it: shaded where the bot cannot go, with a marker at the value a middling +// draw lands on. +class RangeSlider : public juce::Slider { +public: + double lo = 0.0, hi = 1.0, centre = 0.0; + bool centreSet = false; + + void paint(juce::Graphics &g) override { + juce::Slider::paint(g); + + const int y = 0, h = getHeight(); + const float xLo = (float)getPositionOfValue(lo); + const float xHi = (float)getPositionOfValue(hi); + + // Out of the seed's reach. Drawn over the track rather than instead of it, + // so the fader still reads as one continuous control that happens to have + // a usable middle. + g.setColour(juce::Colours::black.withAlpha(0.45f)); + const float left = (float)getPositionOfValue(getMinimum()); + const float right = (float)getPositionOfValue(getMaximum()); + if (xLo > left) + g.fillRect(juce::Rectangle(left, (float)y, xLo - left, (float)h)); + if (right > xHi) + g.fillRect(juce::Rectangle(xHi, (float)y, right - xHi, (float)h)); + + // The ends themselves, so a range that has been narrowed to nothing is + // still visible. + g.setColour(juce::Colours::white.withAlpha(0.55f)); + g.drawVerticalLine((int)xLo, (float)y, (float)(y + h)); + g.drawVerticalLine((int)xHi, (float)y, (float)(y + h)); + + // Where the middle SOUNDS. Hollow until somebody has said so, so an + // unlistened range and a judged one do not look alike. + const float xMid = (float)getPositionOfValue(centre); + juce::Path tri; + tri.addTriangle(xMid - 4.0f, (float)y, xMid + 4.0f, (float)y, xMid, + (float)y + 5.0f); + g.setColour(juce::Colours::orange); + if (centreSet) + g.fillPath(tri); + else + g.strokePath(tri, juce::PathStrokeType(1.0f)); + } +}; + +class KnobRow : public juce::Component { +public: + std::function onChange; + + KnobRow() { + addAndMakeVisible(nameLabel); + nameLabel.setJustificationType(juce::Justification::centredLeft); + + addAndMakeVisible(slider); + slider.setSliderStyle(juce::Slider::LinearHorizontal); + slider.setTextBoxStyle(juce::Slider::TextBoxRight, false, 78, 20); + slider.onValueChange = [this] { + if (knob.value == nullptr || updating) + return; + *knob.value = slider.getValue(); + if (onChange) + onChange(); + }; + + for (auto *b : {&lowButton, &midButton, &highButton}) { + addAndMakeVisible(*b); + b->setConnectedEdges(juce::Button::ConnectedOnLeft | + juce::Button::ConnectedOnRight); + } + // Click GOES to an end; shift-click SETS that end from where the fader is. + // + // Setting is the operation that actually matters -- a range is arrived at + // by moving the fader until it stops sounding right and pinning it there -- + // and doing it by reading the number off the slider and typing it into a + // box is enough friction to stop anybody doing it. + lowButton.setButtonText("|<"); + midButton.setButtonText("<>"); + highButton.setButtonText(">|"); + lowButton.setTooltip("Go to the low end. Shift: set it from the fader."); + midButton.setTooltip("Go to the sonic centre -- the value a middling " + "random draw lands on. Shift: set it from the fader."); + highButton.setTooltip("Go to the high end. Shift: set it from the fader."); + lowButton.onClick = [this] { endButton(End::Low); }; + midButton.onClick = [this] { endButton(End::Centre); }; + highButton.onClick = [this] { endButton(End::High); }; + + for (auto *e : {&lowEditor, &highEditor}) { + addAndMakeVisible(*e); + e->setJustification(juce::Justification::centred); + e->onReturnKey = [this] { commitRange(); }; + e->onFocusLost = [this] { commitRange(); }; + } + } + + void bind(const BandPatch::Knob &k, double outerLow, double outerHigh) { + knob = k; + outerLo = outerLow; + outerHi = outerHigh; + nameLabel.setText(k.name, juce::dontSendNotification); + refresh(); + } + + void refresh() { + if (knob.value == nullptr) + return; + updating = true; + // The FADER spans wider than the range, so a range can be widened by + // moving the fader past its end and pinning it there. + slider.setRange(juce::jmin(outerLo, knob.range->lo), + juce::jmax(outerHi, knob.range->hi), 0.0); + slider.lo = knob.range->lo; + slider.hi = knob.range->hi; + slider.centre = knob.range->mid(); + slider.centreSet = knob.range->centreSet(); + slider.setValue(*knob.value, juce::dontSendNotification); + lowEditor.setText(twoDigits(knob.range->lo), juce::dontSendNotification); + highEditor.setText(twoDigits(knob.range->hi), juce::dontSendNotification); + // A centre nobody has set is shown as the plain marker it is, so "not + // listened to yet" and "deliberately in the middle" do not look alike. + midButton.setButtonText(knob.range->centreSet() ? "<*>" : "<>"); + updating = false; + } + + void resized() override { + auto r = getLocalBounds().reduced(2, 1); + nameLabel.setBounds(r.removeFromLeft(120)); + lowEditor.setBounds(r.removeFromLeft(62).reduced(1)); + lowButton.setBounds(r.removeFromLeft(26)); + midButton.setBounds(r.removeFromLeft(26)); + highButton.setBounds(r.removeFromLeft(26)); + highEditor.setBounds(r.removeFromRight(62).reduced(1)); + slider.setBounds(r); + } + +private: + enum class End { Low, Centre, High }; + + void endButton(End end) { + if (knob.value == nullptr) + return; + + if (juce::ModifierKeys::getCurrentModifiers().isShiftDown()) { + const double here = slider.getValue(); + switch (end) { + case End::Low: + if (here < knob.range->hi) + knob.range->lo = here; + break; + case End::High: + if (here > knob.range->lo) + knob.range->hi = here; + break; + case End::Centre: + if (here > knob.range->lo && here < knob.range->hi) + knob.range->centre = here; + break; + } + *knob.value = knob.range->clamp(*knob.value); + } else { + const double u = end == End::Low ? 0.0 : (end == End::High ? 1.0 : 0.5); + *knob.value = knob.range->at(u); + } + + refresh(); + if (onChange) + onChange(); + } + + void commitRange() { + if (knob.value == nullptr) + return; + const double lo = lowEditor.getText().getDoubleValue(); + const double hi = highEditor.getText().getDoubleValue(); + + // A range with its ends the wrong way round makes a slider that cannot be + // moved, so it is refused rather than accepted and puzzled over later. + if (hi > lo) { + knob.range->lo = lo; + knob.range->hi = hi; + *knob.value = knob.range->clamp(*knob.value); + } + refresh(); + if (onChange) + onChange(); + } + + BandPatch::Knob knob; + double outerLo = 0.0, outerHi = 1.0; + juce::Label nameLabel; + RangeSlider slider; + juce::TextButton lowButton, midButton, highButton; + juce::TextEditor lowEditor, highEditor; + bool updating = false; +}; + +// --------------------------------------------------------------------------- + +// Renders the band off the message thread and hands finished buffers to the +// audio callback. +// +// Double-buffered with an atomic index rather than a lock, because the audio +// thread must not wait for a render that takes a second and a half. The render +// thread fills whichever buffer is not being played and then publishes it; the +// audio thread reads the published index and nothing else. That is the same +// discipline the plugin uses to hand remote audio to its own audio thread. +class BandPlayer : public juce::Thread { +public: + BandPlayer() : juce::Thread("band render") {} + + ~BandPlayer() override { stopThread(2000); } + + struct Report { + float peak = 0.0f; + double lufs = 0.0; + double brightness = 0.0; + double rmsDb = 0.0; + bool valid = false; + }; + + // Called from the message thread. Copies what it needs, so the caller may go + // on editing immediately. + void request(const BandPatch::Band &band, BotBand::Voice voice, bool solo, + const juce::String &keyName, int bpm, int bpi, + std::uint32_t seed) { + { + const juce::ScopedLock sl(requestLock); + pending = band; + pendingVoice = voice; + pendingSolo = solo; + pendingKey = keyName; + pendingBpm = bpm; + pendingBpi = bpi; + pendingSeed = seed; + haveRequest = true; + } + notify(); + } + + void run() override { + while (!threadShouldExit()) { + BandPatch::Band band; + BotBand::Voice voice = BotBand::Voice::Keys; + bool solo = false; + juce::String keyName; + int bpm = 120, bpi = 8; + std::uint32_t seed = 1; + + { + const juce::ScopedLock sl(requestLock); + if (!haveRequest) { + const juce::ScopedUnlock su(requestLock); + wait(200); + continue; + } + band = pending; + voice = pendingVoice; + solo = pendingSolo; + keyName = pendingKey; + bpm = pendingBpm; + bpi = pendingBpi; + seed = pendingSeed; + haveRequest = false; + } + + render(band, voice, solo, keyName, bpm, bpi, seed); + } + } + + // Audio thread. Never blocks and never allocates. + void readInto(juce::AudioBuffer &out, int numSamples) { + const int which = published.load(); + if (which < 0) { + out.clear(); + return; + } + + const auto &src = buffers[which]; + const int length = src.getNumSamples(); + if (length <= 0) { + out.clear(); + return; + } + + int done = 0; + int pos = position; + while (done < numSamples) { + const int chunk = juce::jmin(numSamples - done, length - pos); + for (int ch = 0; ch < out.getNumChannels(); ++ch) + out.copyFrom(ch, done, src, juce::jmin(ch, src.getNumChannels() - 1), + pos, chunk); + pos += chunk; + done += chunk; + if (pos >= length) + pos = 0; + } + position = pos; + } + + Report lastReport() { + const juce::ScopedLock sl(reportLock); + return report; + } + + std::function onRendered; + +private: + void render(BandPatch::Band &band, BotBand::Voice voice, bool solo, + const juce::String &keyName, int bpm, int bpi, + std::uint32_t seed) { + auto key = MusicalKey::parseName(keyName.toStdString()); + if (!key.valid) + key = MusicalKey::parseName("C major"); + + const int n = (int)(kSampleRate * 60.0 / (double)bpm) * bpi; + if (n <= 0) + return; + + // `published` is -1 until something has been rendered, which `readInto` + // guards for and this did not: `1 - -1` is 2, and there are two buffers. + // The first render therefore wrote through a reference past the end of the + // array and freed a pointer that was never allocated, so the lab aborted + // before it could draw a single control. + const int last = published.load(); + const int which = (last == 0) ? 1 : 0; + auto &target = buffers[which]; + target.setSize(2, n * kBars, false, false, true); + target.clear(); + + juce::AudioBuffer one(2, n); + + for (int interval = 0; interval < kBars; ++interval) { + for (int v = 0; v < BotBand::kNumVoices; ++v) { + const auto thisVoice = (BotBand::Voice)v; + if (solo && thisVoice != voice) + continue; + + // Seeded the way PracticeRoom seeds each bot, so the figures are the + // ones the room would produce. + std::uint32_t voiceSeed = seed; + for (int step = 0; step < v; ++step) + voiceSeed = voiceSeed * 1664525u + 1013904223u; + + auto settings = + BotBand::defaults(key, bpm, bpi, kSampleRate, voiceSeed); + settings.usePatchOverrides = true; + settings.keysPatchOverride = band.keysPatch(); + settings.bassPatchOverride = band.bassPatch(); + settings.leadPatchOverride = band.lead; + for (int t = 0; t < BotBand::kNumVoices; ++t) + settings.trimOverride[t] = band.trim[t]; + + one.clear(); + BotBand::renderInterval(thisVoice, settings, interval, + one.getWritePointer(0), one.getWritePointer(1), + n); + if (!BotBand::isStereo(thisVoice)) + one.copyFrom(1, 0, one, 0, 0, n); + + // The far end applies kDefaultRemoteChannelVolume to every remote + // channel, so mix at that level or this is 12 dB hotter than the room. + const float mix = solo ? 1.0f : 0.25f; + for (int ch = 0; ch < 2; ++ch) + target.addFrom(ch, interval * n, one, ch, 0, n, mix); + } + + if (threadShouldExit()) + return; + } + + { + const juce::ScopedLock sl(reportLock); + const int total = target.getNumSamples(); + report.peak = target.getMagnitude(0, total); + report.lufs = AudioMeasure::integratedLufs(target.getReadPointer(0), + target.getReadPointer(1), + total, kSampleRate); + report.brightness = AudioMeasure::brightnessHz(target.getReadPointer(0), + total, kSampleRate); + report.rmsDb = AudioMeasure::toDb( + AudioMeasure::rms(target.getReadPointer(0), total)); + report.valid = true; + } + + published.store(which); + if (onRendered) + juce::MessageManager::callAsync(onRendered); + } + + juce::AudioBuffer buffers[2]; + std::atomic published{-1}; + int position = 0; + + juce::CriticalSection requestLock, reportLock; + BandPatch::Band pending; + BotBand::Voice pendingVoice = BotBand::Voice::Keys; + bool pendingSolo = false; + juce::String pendingKey = "C major"; + int pendingBpm = 120, pendingBpi = 8; + std::uint32_t pendingSeed = 12345; + bool haveRequest = false; + Report report; +}; + +// --------------------------------------------------------------------------- + +class BandLabComponent : public juce::AudioAppComponent { +public: + BandLabComponent() { + band = BandPatch::defaults(); + + setAudioChannels(0, 2); + player.startThread(); + player.onRendered = [this] { showReport(); }; + + addAndMakeVisible(voiceBox); + voiceBox.addItem("Kit (not yet)", 1); + voiceBox.addItem("Bass", 2); + voiceBox.addItem("Keys", 3); + voiceBox.addItem("Lead", 4); + voiceBox.setSelectedId(3); + voiceBox.onChange = [this] { rebuildRows(); }; + + addAndMakeVisible(selectionBox); + selectionBox.onChange = [this] { selectionChanged(); }; + + addAndMakeVisible(playButton); + playButton.setButtonText("Play"); + playButton.setClickingTogglesState(true); + playButton.onClick = [this] { + playing = playButton.getToggleState(); + playButton.setButtonText(playing ? "Stop" : "Play"); + }; + + addAndMakeVisible(soloButton); + soloButton.setButtonText("Solo this voice"); + soloButton.setClickingTogglesState(true); + soloButton.onClick = [this] { rerender(); }; + + addAndMakeVisible(seedLabel); + seedLabel.setText("seed", juce::dontSendNotification); + addAndMakeVisible(seedEditor); + seedEditor.setText("12345"); + seedEditor.onReturnKey = [this] { rerender(); }; + + addAndMakeVisible(keyLabel); + keyLabel.setText("key", juce::dontSendNotification); + addAndMakeVisible(keyEditor); + keyEditor.setText("C major"); + keyEditor.onReturnKey = [this] { rerender(); }; + + addAndMakeVisible(readout); + readout.setJustificationType(juce::Justification::centredLeft); + + addAndMakeVisible(saveButton); + saveButton.setButtonText("Save..."); + saveButton.onClick = [this] { save(); }; + + addAndMakeVisible(loadButton); + loadButton.setButtonText("Load..."); + loadButton.onClick = [this] { load(); }; + + addAndMakeVisible(viewport); + viewport.setViewedComponent(&rows, false); + viewport.setScrollBarsShown(true, false); + + // The mix, always visible: a level is only ever judged against the other + // three, so putting it behind a tab would be putting it out of reach at + // the moment it is needed. + for (int v = 0; v < BotBand::kNumVoices; ++v) { + addAndMakeVisible(trimLabels[v]); + trimLabels[v].setText(BotBand::voiceName((BotBand::Voice)v), + juce::dontSendNotification); + trimLabels[v].setJustificationType(juce::Justification::centred); + + addAndMakeVisible(trimSliders[v]); + trimSliders[v].setSliderStyle(juce::Slider::LinearVertical); + trimSliders[v].setTextBoxStyle(juce::Slider::TextBoxBelow, false, 56, 18); + trimSliders[v].setRange(0.0, 3.0, 0.0); + trimSliders[v].setValue(band.trim[v], juce::dontSendNotification); + trimSliders[v].onValueChange = [this, v] { + band.trim[v] = trimSliders[v].getValue(); + rerender(); + }; + } + + rebuildRows(); + setSize(1000, 700); + } + + ~BandLabComponent() override { shutdownAudio(); } + + void prepareToPlay(int, double) override {} + void releaseResources() override {} + + void getNextAudioBlock(const juce::AudioSourceChannelInfo &info) override { + if (!playing.load()) { + info.clearActiveBufferRegion(); + return; + } + juce::AudioBuffer slice(info.buffer->getArrayOfWritePointers(), + info.buffer->getNumChannels(), + info.startSample, info.numSamples); + player.readInto(slice, info.numSamples); + } + + void paint(juce::Graphics &g) override { + g.fillAll(juce::Colour(0xff1b1f24)); + } + + void resized() override { + auto r = getLocalBounds().reduced(8); + + auto top = r.removeFromTop(30); + voiceBox.setBounds(top.removeFromLeft(140)); + top.removeFromLeft(6); + selectionBox.setBounds(top.removeFromLeft(160)); + top.removeFromLeft(12); + playButton.setBounds(top.removeFromLeft(80)); + top.removeFromLeft(6); + soloButton.setBounds(top.removeFromLeft(140)); + top.removeFromLeft(12); + keyLabel.setBounds(top.removeFromLeft(30)); + keyEditor.setBounds(top.removeFromLeft(100)); + top.removeFromLeft(8); + seedLabel.setBounds(top.removeFromLeft(36)); + seedEditor.setBounds(top.removeFromLeft(80)); + + r.removeFromTop(6); + readout.setBounds(r.removeFromTop(22)); + r.removeFromTop(6); + + auto bottom = r.removeFromBottom(34); + saveButton.setBounds(bottom.removeFromLeft(90)); + bottom.removeFromLeft(6); + loadButton.setBounds(bottom.removeFromLeft(90)); + + auto mix = r.removeFromRight(260); + auto mixLabels = mix.removeFromTop(20); + const int each = mix.getWidth() / BotBand::kNumVoices; + for (int v = 0; v < BotBand::kNumVoices; ++v) { + trimLabels[v].setBounds(mixLabels.removeFromLeft(each)); + trimSliders[v].setBounds(mix.removeFromLeft(each).reduced(4, 0)); + } + + r.removeFromRight(8); + viewport.setBounds(r); + rows.setSize(viewport.getWidth() - 12, (int)rowWidgets.size() * 26); + layoutRows(); + } + +private: + BotBand::Voice currentVoice() const { + return (BotBand::Voice)(voiceBox.getSelectedId() - 1); + } + + void rebuildSelectionBox() { + selectionBox.clear(juce::dontSendNotification); + switch (currentVoice()) { + case BotBand::Voice::Keys: + for (int c = 0; c < 3; ++c) + selectionBox.addItem( + BotVoice::padCharacterName((BotVoice::PadCharacter)c), c + 1); + selectionBox.setSelectedId((int)band.keysCharacter + 1, + juce::dontSendNotification); + break; + case BotBand::Voice::Bass: + for (int t = 0; t < 3; ++t) + selectionBox.addItem( + BotVoice::bassTechniqueName((BotVoice::BassTechnique)t), t + 1); + selectionBox.setSelectedId((int)band.bassTechnique + 1, + juce::dontSendNotification); + break; + case BotBand::Voice::Lead: + for (int i = 0; i < 3; ++i) + selectionBox.addItem( + BotVoice::leadInstrumentName((BotVoice::LeadInstrument)i), i + 1); + selectionBox.setSelectedId((int)band.lead.instrument + 1, + juce::dontSendNotification); + break; + case BotBand::Voice::Drums: + selectionBox.addItem("kit", 1); + selectionBox.setSelectedId(1, juce::dontSendNotification); + break; + } + } + + void selectionChanged() { + const int id = selectionBox.getSelectedId(); + if (id <= 0) + return; + switch (currentVoice()) { + case BotBand::Voice::Keys: + band.keysCharacter = (BotVoice::PadCharacter)(id - 1); + break; + case BotBand::Voice::Bass: + band.bassTechnique = (BotVoice::BassTechnique)(id - 1); + break; + case BotBand::Voice::Lead: + band.lead.instrument = (BotVoice::LeadInstrument)(id - 1); + break; + case BotBand::Voice::Drums: + break; + } + rebuildRows(); + } + + void rebuildRows() { + rebuildSelectionBox(); + + const auto knobs = BandPatch::knobsFor(band, currentVoice()); + + // The fader's extent comes from the SHIPPED range, not the current one, so + // it stays put while a range is edited. Anchoring it to the live range + // would shrink the fader every time the range was narrowed, and there would + // be no way back out. + static BandPatch::Band shipped = BandPatch::defaults(); + shipped.keysCharacter = band.keysCharacter; + shipped.bassTechnique = band.bassTechnique; + shipped.lead.instrument = band.lead.instrument; + const auto shippedKnobs = BandPatch::knobsFor(shipped, currentVoice()); + + rowWidgets.clear(); + for (size_t i = 0; i < knobs.size(); ++i) { + const auto &knob = knobs[i]; + // Half the shipped width beyond each end. Not below zero when the + // shipped range was not: a negative level or decay time is not a sound + // to go looking for, where a negative detune is. + double outerLo = knob.range->lo, outerHi = knob.range->hi; + if (i < shippedKnobs.size() && shippedKnobs[i].range != nullptr) { + const auto &sr = *shippedKnobs[i].range; + const double span = sr.hi - sr.lo; + outerLo = sr.lo - 0.5 * span; + outerHi = sr.hi + 0.5 * span; + if (sr.lo >= 0.0) + outerLo = juce::jmax(0.0, outerLo); + } + + auto row = std::make_unique(); + row->bind(knob, outerLo, outerHi); + row->onChange = [this] { rerender(); }; + rows.addAndMakeVisible(*row); + rowWidgets.push_back(std::move(row)); + } + + rows.setSize(juce::jmax(400, viewport.getWidth() - 12), + (int)rowWidgets.size() * 26); + layoutRows(); + rerender(); + } + + void layoutRows() { + int y = 0; + for (auto &row : rowWidgets) { + row->setBounds(0, y, rows.getWidth(), 25); + y += 26; + } + } + + void rerender() { + player.request(band, currentVoice(), soloButton.getToggleState(), + keyEditor.getText(), 120, 8, + (std::uint32_t)seedEditor.getText().getLargeIntValue()); + } + + void showReport() { + const auto r = player.lastReport(); + if (!r.valid) + return; + readout.setText("peak " + juce::String(r.peak, 3) + " " + + juce::String(r.rmsDb, 1) + " dBFS " + + juce::String(r.lufs, 1) + " LUFS brightness " + + juce::String(r.brightness, 0) + " Hz", + juce::dontSendNotification); + } + + void save() { + chooser = std::make_unique( + "Save these settings", + juce::File::getSpecialLocation(juce::File::userHomeDirectory) + .getChildFile("band-patch.txt"), + "*.txt"); + chooser->launchAsync(juce::FileBrowserComponent::saveMode | + juce::FileBrowserComponent::canSelectFiles, + [this](const juce::FileChooser &fc) { + const auto file = fc.getResult(); + if (file == juce::File()) + return; + file.replaceWithText(BandPatch::write(band)); + readout.setText("wrote " + file.getFullPathName(), + juce::dontSendNotification); + }); + } + + void load() { + chooser = std::make_unique( + "Load settings", + juce::File::getSpecialLocation(juce::File::userHomeDirectory), "*.txt"); + chooser->launchAsync( + juce::FileBrowserComponent::openMode | + juce::FileBrowserComponent::canSelectFiles, + [this](const juce::FileChooser &fc) { + const auto file = fc.getResult(); + if (file == juce::File()) + return; + std::string error; + if (!BandPatch::read(file.loadFileAsString().toStdString(), band, + error)) { + readout.setText(error, juce::dontSendNotification); + return; + } + for (int v = 0; v < BotBand::kNumVoices; ++v) + trimSliders[v].setValue(band.trim[v], juce::dontSendNotification); + rebuildRows(); + }); + } + + BandPatch::Band band; + BandPlayer player; + std::atomic playing{false}; + + juce::ComboBox voiceBox, selectionBox; + juce::TextButton playButton, soloButton, saveButton, loadButton; + juce::Label seedLabel, keyLabel, readout; + juce::TextEditor seedEditor, keyEditor; + juce::Viewport viewport; + juce::Component rows; + std::vector> rowWidgets; + juce::Label trimLabels[BotBand::kNumVoices]; + juce::Slider trimSliders[BotBand::kNumVoices]; + std::unique_ptr chooser; +}; + +// --------------------------------------------------------------------------- + +class BandLabApplication : public juce::JUCEApplication { +public: + const juce::String getApplicationName() override { return "AntiphonBandLab"; } + const juce::String getApplicationVersion() override { return "0.1"; } + + void initialise(const juce::String &) override { + window = std::make_unique(); + } + + void shutdown() override { window = nullptr; } + +private: + class Window : public juce::DocumentWindow { + public: + Window() + : juce::DocumentWindow("Antiphon Band Lab", juce::Colour(0xff1b1f24), + juce::DocumentWindow::allButtons) { + setUsingNativeTitleBar(true); + setContentOwned(new BandLabComponent(), true); + setResizable(true, false); + centreWithSize(1000, 700); + setVisible(true); + } + + void closeButtonPressed() override { + juce::JUCEApplication::getInstance()->systemRequestedQuit(); + } + }; + + std::unique_ptr window; +}; + +} // namespace + +START_JUCE_APPLICATION(BandLabApplication) diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt index aed810a..b666688 100644 --- a/tools/CMakeLists.txt +++ b/tools/CMakeLists.txt @@ -7,9 +7,10 @@ # X11/ALSA, which would stop this running on a headless box, which is exactly # where you would want to batch-convert an archive. # -# VorbisCodec.cpp is re-listed here rather than shared, matching the convention -# explained at the top of test/CMakeLists.txt. It is the one source under src/ -# that includes no JUCE at all, so it compiles into any target. +# The Ogg decoder is no longer re-listed here: it moved to chalkwalk-ninjam and +# arrives as a library target, which is the whole point of the extraction. What +# is still re-listed from src/ follows the convention explained at the top of +# test/CMakeLists.txt. juce_add_console_app(AntiphonStems COMPANY_NAME "Chalkwalk" @@ -19,14 +20,14 @@ juce_generate_juce_header(AntiphonStems) target_sources(AntiphonStems PRIVATE StemsMain.cpp - ${CMAKE_SOURCE_DIR}/src/ClipsortLog.cpp - ${CMAKE_SOURCE_DIR}/src/VorbisCodec.cpp) + ${CMAKE_SOURCE_DIR}/src/ClipsortLog.cpp) target_compile_definitions(AntiphonStems PRIVATE JUCE_WEB_BROWSER=0 JUCE_USE_CURL=0) target_link_libraries(AntiphonStems + PRIVATE chalkwalk::music chalkwalk::dsp chalkwalk::jambot chalkwalk::ninjam PRIVATE juce::juce_audio_formats juce::juce_events @@ -37,3 +38,118 @@ target_link_libraries(AntiphonStems juce::juce_recommended_config_flags) target_include_directories(AntiphonStems PRIVATE ${CMAKE_SOURCE_DIR}/src) + +# antiphon-voicelab: render one bot voice to a WAV and measure it. +# +# A development instrument for tuning the synthesis by ear, not something a +# player ever runs -- so it is built but not installed. It links the band's own +# sources rather than copies of them, which is the point: what it renders is +# what the room hears, and the numbers it prints come from the same +# src/AudioMeasure.h the unit tests assert against. +# +# Same light recipe as antiphon-stems: no juce_audio_utils, so it runs on a +# headless box. + +juce_add_console_app(AntiphonVoiceLab + COMPANY_NAME "Chalkwalk" + PRODUCT_NAME "antiphon-voicelab") + +juce_generate_juce_header(AntiphonVoiceLab) + +target_sources(AntiphonVoiceLab PRIVATE + VoiceLabMain.cpp) + +target_compile_definitions(AntiphonVoiceLab PRIVATE + JUCE_WEB_BROWSER=0 + JUCE_USE_CURL=0) + +target_link_libraries(AntiphonVoiceLab + PRIVATE chalkwalk::music chalkwalk::dsp chalkwalk::jambot chalkwalk::dsp::measure chalkwalk::ninjam + PRIVATE + juce::juce_audio_formats + juce::juce_events + PUBLIC + juce::juce_recommended_config_flags) + +target_include_directories(AntiphonVoiceLab PRIVATE ${CMAKE_SOURCE_DIR}/src) + +# antiphon-bandlab: a control surface for the band's synthesis. +# +# The one target here that is a GUI, and the one that links juce_audio_devices +# and juce_audio_utils -- it has to open an output and loop what it renders, so +# the headless rule the other two follow does not apply and cannot. It will not +# start on a machine with no display or no audio device, which is fine: it is a +# tuning instrument, and tuning is done at a desk with speakers. +# +# Like antiphon-voicelab it links the band's own sources rather than copies, so +# what it plays is what the room plays. + +juce_add_gui_app(AntiphonBandLab + COMPANY_NAME "Chalkwalk" + PRODUCT_NAME "antiphon-bandlab") + +juce_generate_juce_header(AntiphonBandLab) + +target_sources(AntiphonBandLab PRIVATE + BandLabMain.cpp) + +target_compile_definitions(AntiphonBandLab PRIVATE + JUCE_WEB_BROWSER=0 + JUCE_USE_CURL=0) + +target_link_libraries(AntiphonBandLab + PRIVATE chalkwalk::music chalkwalk::dsp chalkwalk::jambot chalkwalk::dsp::measure chalkwalk::ninjam + PRIVATE + juce::juce_audio_utils + PUBLIC + juce::juce_recommended_config_flags) + +target_include_directories(AntiphonBandLab PRIVATE ${CMAKE_SOURCE_DIR}/src) + +# --------------------------------------------------------------------------- +# antiphon-practice: host a practice room and wait for you to join it. +# +# The room is a destination rather than a mode, so hosting it and playing in it +# are separate jobs: this process is the far side of the connection and renders +# nothing locally. Console app, no audio device, no GUI -- juce_audio_utils is +# NOT linked, for the same reason as AntiphonStems. +# +# Exists because nothing in src/ constructs a PracticeRoom yet; see ROADMAP.md. +# --------------------------------------------------------------------------- + +juce_add_console_app(AntiphonPractice + COMPANY_NAME "Chalkwalk" + PRODUCT_NAME "antiphon-practice") + +juce_generate_juce_header(AntiphonPractice) + +target_sources(AntiphonPractice PRIVATE + PracticeRoomMain.cpp + ${CMAKE_SOURCE_DIR}/src/PracticeRoom.cpp + ${CMAKE_SOURCE_DIR}/src/PracticeServer.cpp + ${CMAKE_SOURCE_DIR}/src/ChatFormat.cpp + ${CMAKE_SOURCE_DIR}/src/NinjamClient.cpp + ${CMAKE_SOURCE_DIR}/src/MetronomeVoice.cpp + ${CMAKE_SOURCE_DIR}/src/ClipsortLog.cpp + ${CMAKE_SOURCE_DIR}/src/SessionWriter.cpp) + +target_compile_definitions(AntiphonPractice PRIVATE + JUCE_WEB_BROWSER=0 + JUCE_USE_CURL=0 + # runDispatchLoopUntil is gated behind this. The bots' chat handling and + # their arrival timer both run on the message thread, so this process + # has to pump it or the band is deaf. + JUCE_MODAL_LOOPS_PERMITTED=1) + +target_link_libraries(AntiphonPractice + PRIVATE chalkwalk::music chalkwalk::dsp chalkwalk::jambot chalkwalk::ninjam + PRIVATE + juce::juce_audio_formats + juce::juce_events + ogg + vorbis + vorbisenc + PUBLIC + juce::juce_recommended_config_flags) + +target_include_directories(AntiphonPractice PRIVATE ${CMAKE_SOURCE_DIR}/src) diff --git a/tools/PracticeRoomMain.cpp b/tools/PracticeRoomMain.cpp new file mode 100644 index 0000000..525863b --- /dev/null +++ b/tools/PracticeRoomMain.cpp @@ -0,0 +1,114 @@ +#include "PracticeRoom.h" +#include +#include +#include +#include + +// antiphon-practice: hosts a practice room and waits, so you can join it with +// the ordinary client and find out what having a band in the room feels like. +// +// The room was designed as a DESTINATION rather than a mode (`PracticeRoom.h`): +// a real server on loopback with real bots connected to it, so the whole +// connected UI -- phase bar, remote strips, routing, chat, recording -- works +// without knowing the room is any different. That design is what makes this +// tool a dozen lines rather than a feature: there is nothing to integrate, only +// something to start. +// +// It exists because the room is not reachable from the plugin yet. Nothing in +// `src/` constructs a PracticeRoom; only the tests do. This closes that gap for +// a human without pretending the feature is finished, and it is the harness the +// remaining chat work needs anyway. +// +// ./build/tools/AntiphonPractice_artefacts/antiphon-practice +// ...then connect the standalone to 127.0.0.1: +// +// Console app on purpose: it renders no audio locally and opens no device. The +// band's audio reaches you through the server, the same way another player's +// would. + +namespace { + +std::atomic stopping{false}; + +void onSignal(int) { stopping.store(true); } + +juce::String flag(const juce::StringArray &args, const juce::String &name, + const juce::String &fallback) { + const int i = args.indexOf(name); + return (i >= 0 && i + 1 < args.size()) ? args[i + 1] : fallback; +} + +} // namespace + +int main(int argc, char **argv) { + juce::StringArray args; + for (int i = 1; i < argc; ++i) + args.add(juce::String(argv[i])); + + if (args.contains("--help") || args.contains("-h")) { + std::cout + << "antiphon-practice -- host a practice room and wait\n\n" + " --bpm N tempo (default 120)\n" + " --bpi N beats per interval (default 8)\n" + " --rate N sample rate (default 48000)\n" + " --key NAME starting key, e.g. \"D minor\" (default C major)\n" + " --seed N band seed; the same seed is the same band\n" + " --owner NAME the username the band treats as its owner\n\n" + "Then connect the standalone to 127.0.0.1 on the port printed.\n"; + return 0; + } + + juce::ScopedJuceInitialiser_GUI juceInit; + + PracticeRoom::Config cfg; + cfg.bpm = flag(args, "--bpm", "120").getIntValue(); + cfg.bpi = flag(args, "--bpi", "8").getIntValue(); + cfg.sampleRate = flag(args, "--rate", "48000").getDoubleValue(); + cfg.ownerName = flag(args, "--owner", "you"); + cfg.seed = (std::uint32_t)flag(args, "--seed", "20260811").getLargeIntValue(); + + const auto keyName = flag(args, "--key", "C major"); + if (const auto key = MusicalKey::parseName(keyName.toStdString()); + key.valid) { + cfg.key = key; + } else { + std::cerr << "not a key: " << keyName << "\n"; + return 2; + } + + PracticeRoom room; + if (!room.start(cfg)) { + std::cerr << "could not start the practice room\n"; + return 1; + } + + std::signal(SIGINT, onSignal); + std::signal(SIGTERM, onSignal); + + std::cout << "practice room on " << PracticeRoom::host() << ":" << room.port() + << "\n" + << " " << cfg.bpm << " bpm, " << cfg.bpi << " bpi, " + << MusicalKey::displayName(cfg.key) << ", seed " << cfg.seed << "\n" + << " band: " << room.botNames().joinIntoString(", ") << "\n\n" + << "connect the standalone to that address as \"" << cfg.ownerName + << "\".\n" + << "in chat: \"shake\" rerolls, \"[key: D minor]\" or \"/key D " + "minor\" moves the key,\n" + << "a line like \"| Am | F | C | G |\" sets the chart, \"part\" " + "sends them home.\n\n" + << "ctrl-c to stop.\n"; + std::cout.flush(); + + // RUN THE MESSAGE LOOP. Everything a bot does in response to the room -- + // every chat callback, via NinjamClient's callAsyncIfAlive, and every + // juce::Timer, including the arrival roster -- runs on the message thread. + // Sleeping here instead queued all of it and ran none of it: the band played + // perfectly and ignored every word said to it, because audio is driven by the + // conductor and network threads and needs no loop at all. + while (!stopping.load() && room.isRunning()) + juce::MessageManager::getInstance()->runDispatchLoopUntil(200); + + std::cout << "\nstopping...\n"; + room.stop(); + return 0; +} diff --git a/tools/VoiceLabMain.cpp b/tools/VoiceLabMain.cpp new file mode 100644 index 0000000..ec6476f --- /dev/null +++ b/tools/VoiceLabMain.cpp @@ -0,0 +1,1003 @@ +// antiphon-voicelab: render one bot voice to a WAV and measure it. +// +// A development instrument, not a shipped one. Physical models are tuned by ear +// over dozens of small changes, and the loop that existed before this -- edit a +// constant, rebuild, run the suite, write an audition -- was far too slow to +// converge on a sound. +// +// Every parameter is a flag, so trying a value costs no rebuild. It prints the +// same quantities the unit tests assert, from the same header +// (src/AudioMeasure.h), so tuning by ear and setting a test threshold use one +// instrument rather than two that can disagree (`PRINCIPLES §5`, `§8`). +// +// Follows tools/StemsMain.cpp: a console app with juce_audio_formats for the +// WAV writer and nothing that needs a display. + +#include + +#include "AudioMeasure.h" +#include +#include +#include "MusicalKey.h" + +#include + +#include + +namespace { + +struct Options { + juce::String voice; + juce::File out; + double sampleRate = 48000.0; + double seconds = 2.0; + float velocity = 0.8f; + int midiNote = 40; // E2, a bass note + std::uint32_t seed = 1; + int articulation = chalkwalk::music::kArticulationNatural; + bool open = false; + int repeats = 1; + double spacing = 0.5; + + // Band mode. + juce::String keyName = "C major"; + int bpm = 120, bpi = 8, bars = 4; + + // Bass articulation. + BotVoice::BassTechnique technique = BotVoice::BassTechnique::Fingered; + + // Which polysynth patch. Named on the command line, or left alone to take + // whatever --seed would have given the keyboard player. + bool patchNamed = false; + BotVoice::PadCharacter patchCharacter = BotVoice::PadCharacter::Poly; + + // And which instrument the soloist is holding. + BotVoice::LeadInstrument instrument = BotVoice::LeadInstrument::Synth; + bool instrumentNamed = false; + + // Sweep. + juce::String sweepParam; + double sweepLo = 0.0, sweepHi = 1.0; + int sweepCount = 5; + + // Normalise the output to this integrated loudness, so two renders can be + // compared for timbre without one of them simply being louder. + bool matchLufs = false; + double targetLufs = -18.0; +}; + +void usage() { + std::printf( + "AntiphonVoiceLab -- render and measure one bot voice\n" + "\n" + " AntiphonVoiceLab [options]\n" + "\n" + "voices: kick snare hat bass lead pad kit keys solo band\n" + " file measure WAVs that already exist, and with --lufs\n" + " write matched copies -- for comparing renders from\n" + " builds you can no longer reproduce\n" + " kit, keys and band go through the real path -- with the kit's room " + "and\n" + " the keyboard's chorus -- in stereo\n" + "\n" + " -o output file, or directory when sweeping\n" + " --sr sample rate (default 48000)\n" + " --seconds length of one hit or note (default 2)\n" + " --velocity <0..1> how hard (default 0.8)\n" + " --note pitch for pitched voices: E1, A#2, Bb3, or 40\n" + " --seed noise seed, and the band's seed\n" + " --open open hat\n" + " --technique bass articulation: fingered, picked or muted\n" + " --patch polysynth patch: strings, brass or poly\n" + " --instrument what the soloist is holding: epiano, guitar, " + "synth\n" + " --repeats render n hits (default 1)\n" + " --spacing seconds between repeats (default 0.5)\n" + " --sweep p=lo:hi:n one file per value of p; p is velocity or note\n" + " --lufs normalise the output to this integrated loudness,\n" + " so an A/B is about timbre and not about level\n" + "\n" + "band mode only:\n" + " --key C major, D minor, F# Dorian (default C major)\n" + " --bpm --bpi --bars \n" + " --articulation 0 staccato, 50 as written, 100 legato\n" + "\n" + "lead analysis:\n" + " leadstats the lead's melodic interval histogram --\n" + " what the line actually DOES, seed after seed\n" + " (--repeats seeds, --bars intervals each)\n" + "\n" + "Prints peak, rms, crest, fundamental and brightness for what it wrote.\n" + "Those are the quantities the unit tests assert, measured the same " + "way.\n"); +} + +// "E1", "A#2", "Bb3", or a plain MIDI number. +bool parseNote(const juce::String &text, int &midiOut) { + const auto s = text.trim(); + if (s.isEmpty()) + return false; + if (s.containsOnly("0123456789-")) { + midiOut = s.getIntValue(); + return true; + } + + static const char *letters = "CDEFGAB"; + static const int semis[7] = {0, 2, 4, 5, 7, 9, 11}; + const juce::juce_wchar raw = s[0]; + const juce::juce_wchar upper = + (raw >= 'a' && raw <= 'z') ? (juce::juce_wchar)(raw - 32) : raw; + const int idx = juce::String(letters).indexOfChar(upper); + if (idx < 0) + return false; + + int pc = semis[idx]; + int pos = 1; + while (pos < s.length() && (s[pos] == '#' || s[pos] == 'b')) { + pc += (s[pos] == '#') ? 1 : -1; + ++pos; + } + if (pos >= s.length()) + return false; + + const int octave = s.substring(pos).getIntValue(); + midiOut = 12 * (octave + 1) + pc; + return true; +} + +// The patch to audition. +// +// Naming one on the command line does NOT override the fields of whatever the +// seed gave -- the ranges are per-character, so a strings patch with a brass +// label would be a sound the band can never produce. It walks the seed forward +// until it lands on the character asked for, so what gets rendered is always a +// patch the seed could really have chosen. +BotVoice::PadPatch patchFor(const Options &o, std::uint32_t seed) { + auto patch = BotVoice::padPatchFor(seed); + if (!o.patchNamed) + return patch; + + for (int tries = 0; tries < 64 && patch.character != o.patchCharacter; + ++tries) + patch = + BotVoice::padPatchFor(seed + 2654435761u * (std::uint32_t)(tries + 1)); + return patch; +} + +// One hit or note of a single voice, rendered into a fresh buffer. +std::vector renderOne(const Options &o) { + const int hit = juce::jmax(1, (int)(o.seconds * o.sampleRate)); + const int gap = juce::jmax(0, (int)(o.spacing * o.sampleRate)); + const int total = hit + (o.repeats - 1) * juce::jmax(gap, 1); + std::vector buf((size_t)total, 0.0f); + + const double hz = BotVoice::midiToHz((double)o.midiNote); + + for (int r = 0; r < o.repeats; ++r) { + const int at = r * gap; + if (at >= total) + break; + float *out = buf.data() + at; + const int room = total - at; + const std::uint32_t seed = o.seed + 977u * (std::uint32_t)r; + + if (o.voice == "kick") + BotVoice::renderKick(out, room, o.sampleRate, o.velocity); + else if (o.voice == "snare") + BotVoice::renderSnare(out, room, o.sampleRate, o.velocity, seed); + else if (o.voice == "hat") + BotVoice::renderHat(out, room, o.sampleRate, o.velocity, seed, o.open); + else if (o.voice == "bass") + BotVoice::renderBassString(out, juce::jmin(room, hit), o.sampleRate, hz, + o.velocity, + BotVoice::bassPatchFor(o.technique), seed); + else if (o.voice == "lead") { + const int span = juce::jmin(room, hit); + BotVoice::LeadPatch patch; + patch.instrument = o.instrument; + BotVoice::renderLead(out, span, (int)(0.6 * span), o.sampleRate, hz, + o.velocity, patch, seed); + } else if (o.voice == "pad") { + const auto patch = patchFor(o, seed); + if (r == 0) + std::printf(" patch %s: detune %.1f cents, cutoff %.1f partials, " + "res %.2f, env x%.1f, attack %.0f ms, drive %.2f\n", + BotVoice::padCharacterName(patch.character), + patch.detuneCents, patch.cutoffPartials, patch.resonance, + patch.envAmount, 1000.0 * patch.attackSeconds, patch.drive); + // Held for most of the render, so the release is heard as part of the + // note rather than falling off the end of the file. + const int span = juce::jmin(room, hit); + BotVoice::renderPad(out, span, (int)(0.6 * span), o.sampleRate, hz, + o.velocity, patch, seed); + } + } + return buf; +} + +// The whole band through the real BotBand path, seeded the way PracticeRoom +// seeds it, so what comes out is what the room would hear. +// One voice through the real BotBand path -- with its room, and in stereo if it +// has one. Distinct from `renderOne`, which drives a bare BotVoice function and +// so hears the drum without the kit around it. +void renderVoice(const Options &o, BotBand::Voice voice, + std::vector &left, std::vector &right) { + auto key = MusicalKey::parseName(o.keyName.toStdString()); + if (!key.valid) + key = MusicalKey::parseName("C major"); + + auto settings = BotBand::defaults(key, o.bpm, o.bpi, o.sampleRate, o.seed); + + settings.articulation = o.articulation; + const int n = (int)(o.sampleRate * 60.0 / o.bpm) * o.bpi; + + left.clear(); + right.clear(); + for (int interval = 0; interval < o.bars; ++interval) { + std::vector l((size_t)n, 0.0f), r((size_t)n, 0.0f); + BotBand::renderInterval(voice, settings, interval, l.data(), r.data(), n); + if (!BotBand::isStereo(voice)) + r = l; + left.insert(left.end(), l.begin(), l.end()); + right.insert(right.end(), r.begin(), r.end()); + } +} + +void renderBandStereo(const Options &o, std::vector &mixL, + std::vector &mixR) { + auto key = MusicalKey::parseName(o.keyName.toStdString()); + if (!key.valid) + key = MusicalKey::parseName("C major"); + + mixL.clear(); + mixR.clear(); + for (int interval = 0; interval < o.bars; ++interval) { + std::vector accL, accR; + for (auto voice : {BotBand::Voice::Drums, BotBand::Voice::Bass, + BotBand::Voice::Keys, BotBand::Voice::Lead}) { + std::uint32_t seed = o.seed; + for (int step = 0; step < (int)voice; ++step) + seed = seed * 1664525u + 1013904223u; + + auto settings = BotBand::defaults(key, o.bpm, o.bpi, o.sampleRate, seed); + + settings.articulation = o.articulation; + const int n = (int)(o.sampleRate * 60.0 / o.bpm) * o.bpi; + if (accL.empty()) { + accL.assign((size_t)n, 0.0f); + accR.assign((size_t)n, 0.0f); + } + + std::vector l((size_t)n, 0.0f), r((size_t)n, 0.0f); + BotBand::renderInterval(voice, settings, interval, l.data(), r.data(), n); + if (!BotBand::isStereo(voice)) + r = l; + + if (interval == 0) { + // As the pair that goes out: a mono voice is duplicated by the bot, so + // measuring one channel would report it 3 LU under the kit for no + // reason but arithmetic. + const double lufs = + AudioMeasure::integratedLufs(l.data(), r.data(), n, o.sampleRate); + std::printf(" %-6s peak %.3f rms %6.1f dBFS %6.1f LUFS " + "brightness %7.1f Hz%s\n", + BotBand::voiceName(voice), AudioMeasure::peak(l.data(), n), + AudioMeasure::toDb(AudioMeasure::rms(l.data(), n)), lufs, + AudioMeasure::brightnessHz(l.data(), n, o.sampleRate), + BotBand::isStereo(voice) ? " stereo" : ""); + } + + // The far end applies kDefaultRemoteChannelVolume to every remote + // channel, so mix at that level or this is 12 dB hotter than the room. + for (int j = 0; j < n; ++j) { + accL[(size_t)j] += 0.25f * l[(size_t)j]; + accR[(size_t)j] += 0.25f * r[(size_t)j]; + } + } + mixL.insert(mixL.end(), accL.begin(), accL.end()); + mixR.insert(mixR.end(), accR.begin(), accR.end()); + } +} + +std::vector renderBand(const Options &o) { + auto key = MusicalKey::parseName(o.keyName.toStdString()); + if (!key.valid) + key = MusicalKey::parseName("C major"); + + std::vector mix; + for (int interval = 0; interval < o.bars; ++interval) { + std::vector acc; + for (auto voice : {BotBand::Voice::Drums, BotBand::Voice::Bass, + BotBand::Voice::Keys, BotBand::Voice::Lead}) { + std::uint32_t s = o.seed; + for (int step = 0; step < (int)voice; ++step) + s = s * 1664525u + 1013904223u; + + auto settings = BotBand::defaults(key, o.bpm, o.bpi, o.sampleRate, s); + + settings.articulation = o.articulation; + const int n = (int)(o.sampleRate * 60.0 / o.bpm) * o.bpi; + if (acc.empty()) + acc.assign((size_t)n, 0.0f); + + std::vector one((size_t)n, 0.0f); + BotBand::renderInterval(voice, settings, interval, one.data(), n); + + if (interval == 0) { + // Each voice on its own, before it is summed, so a problem can be + // pinned on a player rather than on the band. + std::printf(" %-6s peak %.3f rms %.4f (%6.1f dBFS) f0 %7.1f Hz " + "brightness %7.1f Hz\n", + BotBand::voiceName(voice), + AudioMeasure::peak(one.data(), n), + AudioMeasure::rms(one.data(), n), + AudioMeasure::toDb(AudioMeasure::rms(one.data(), n)), + AudioMeasure::fundamentalHz(one.data(), n, o.sampleRate), + AudioMeasure::brightnessHz(one.data(), n, o.sampleRate)); + } + + // The far end applies kDefaultRemoteChannelVolume to every remote + // channel, so mix at that level or this is 12 dB hotter than the room. + for (int j = 0; j < n; ++j) + acc[(size_t)j] += 0.25f * one[(size_t)j]; + } + mix.insert(mix.end(), acc.begin(), acc.end()); + } + return mix; +} + +void report(const juce::String &label, const std::vector &buf, + double sampleRate, const std::vector *right = nullptr) { + const int n = (int)buf.size(); + + // Measured as the pair that actually goes out, because a bot always + // transmits two channels -- so a mono voice is measured duplicated, which is + // what the listener hears, rather than 3 LU quieter than the kit for no + // reason but arithmetic. + const double lufs = + right != nullptr + ? AudioMeasure::integratedLufs(buf.data(), right->data(), n, + sampleRate) + : AudioMeasure::integratedLufs(buf.data(), buf.data(), n, sampleRate); + + juce::String loudness = lufs <= AudioMeasure::kSilenceLufs + ? juce::String(" -- ") + : juce::String(lufs, 1); + + std::printf("%-22s peak %.3f rms %.4f (%6.1f dBFS) %6s LUFS crest %.2f " + "f0 %7.1f Hz brightness %7.1f Hz\n", + label.toRawUTF8(), AudioMeasure::peak(buf.data(), n), + AudioMeasure::rms(buf.data(), n), + AudioMeasure::toDb(AudioMeasure::rms(buf.data(), n)), + loudness.toRawUTF8(), AudioMeasure::crest(buf.data(), n), + AudioMeasure::fundamentalHz(buf.data(), n, sampleRate), + AudioMeasure::brightnessHz(buf.data(), n, sampleRate)); + + // A bare voice has no ceiling on it -- that lives in BotBand, so what the + // band renders can never clip and what the lab renders can. Overlapping + // repeats are the usual way to get there, and a clipped file listened to as + // a comparison is a comparison of the clipping. + if (AudioMeasure::peak(buf.data(), n) > 0.99f) + std::printf(" WARNING: peaks at %.2f and will clip in the file. Lower " + "--velocity, or space the repeats so they do not overlap.\n", + AudioMeasure::peak(buf.data(), n)); +} + +// Bring a render onto a target loudness, so an A/B is about timbre rather than +// about which one is louder. Reports what it did, because a comparison that +// silently changed the level is a comparison you cannot trust. +void matchLoudness(const Options &o, std::vector &left, + std::vector *right) { + if (!o.matchLufs || left.empty()) + return; + + const int n = (int)left.size(); + const double measured = + right != nullptr ? AudioMeasure::integratedLufs( + left.data(), right->data(), n, o.sampleRate) + : AudioMeasure::integratedLufs(left.data(), left.data(), + n, o.sampleRate); + if (measured <= AudioMeasure::kSilenceLufs) { + std::printf(" (too short or too quiet to match loudness)\n"); + return; + } + + const double gain = AudioMeasure::gainForLufs(measured, o.targetLufs); + for (auto &x : left) + x = (float)(x * gain); + if (right != nullptr) + for (auto &x : *right) + x = (float)(x * gain); + + std::printf(" matched %.1f -> %.1f LUFS (%+.1f dB)\n", measured, + o.targetLufs, 20.0 * std::log10(gain)); + + // A loudness target and a peak ceiling are different things, and a sparse + // percussive voice hits the second long before the first: matching a hi-hat + // to -18 LUFS wants +11 dB and sends its peaks to 1.5. The file would be + // clipped on the way out and the comparison would be of distortion, so say + // so and name the target that would have fitted. + const float peak = AudioMeasure::peak(left.data(), n); + if (peak > 0.99f) { + const double headroom = 20.0 * std::log10((double)peak); + std::printf(" WARNING: peaks at %.2f, so this file WILL clip. This voice " + "is too sparse for %.1f LUFS -- try --lufs %.1f\n", + peak, o.targetLufs, o.targetLufs - headroom - 0.5); + } +} + +bool writeWav(const juce::File &file, const std::vector &buf, + double sampleRate, + const std::vector *rightChannel = nullptr) { + file.deleteFile(); + file.getParentDirectory().createDirectory(); + + juce::WavAudioFormat wav; + std::unique_ptr stream(file.createOutputStream()); + if (stream == nullptr) + return false; + + const int channels = rightChannel != nullptr ? 2 : 1; + std::unique_ptr writer(wav.createWriterFor( + stream.release(), sampleRate, (unsigned)channels, 24, {}, 0)); + if (writer == nullptr) + return false; + + juce::AudioBuffer out(channels, (int)buf.size()); + for (int i = 0; i < (int)buf.size(); ++i) { + out.setSample(0, i, buf[(size_t)i]); + if (channels > 1) + out.setSample(1, i, + i < (int)rightChannel->size() ? (*rightChannel)[(size_t)i] + : 0.0f); + } + writer->writeFromAudioSampleBuffer(out, 0, out.getNumSamples()); + return true; +} + +// Measure a WAV that already exists, and optionally write a loudness-matched +// copy of it. +// +// The point of this is comparing renders that CANNOT be regenerated: a band +// from three commits ago is a file and nothing else, and the only fair way to +// A/B it against today's is to bring both to the same integrated loudness. +int measureFile(const Options &o, const juce::File &input) { + juce::WavAudioFormat wav; + std::unique_ptr reader( + wav.createReaderFor(new juce::FileInputStream(input), true)); + if (reader == nullptr) { + std::fprintf(stderr, "voicelab: could not read %s\n", + input.getFullPathName().toRawUTF8()); + return 1; + } + + const int n = (int)reader->lengthInSamples; + const int channels = (int)reader->numChannels; + const double rate = reader->sampleRate; + + juce::AudioBuffer buf(juce::jmax(1, channels), juce::jmax(1, n)); + buf.clear(); + reader->read(&buf, 0, n, 0, true, channels > 1); + + std::vector left((size_t)n), right((size_t)n); + for (int i = 0; i < n; ++i) { + left[(size_t)i] = buf.getSample(0, i); + right[(size_t)i] = channels > 1 ? buf.getSample(1, i) : buf.getSample(0, i); + } + + Options local = o; + local.sampleRate = rate; + + const double before = + AudioMeasure::integratedLufs(left.data(), right.data(), n, rate); + std::printf("%-34s %2d ch %5.0f Hz %6.2f s peak %.3f %6.1f LUFS\n", + input.getFileName().toRawUTF8(), channels, rate, (double)n / rate, + AudioMeasure::peak(left.data(), n), before); + + if (!o.matchLufs) + return 0; + + matchLoudness(local, left, &right); + + const juce::File out = + o.out != juce::File() + ? o.out + : input.getSiblingFile(input.getFileNameWithoutExtension() + + "-matched.wav"); + if (!writeWav(out, left, rate, &right)) { + std::fprintf(stderr, "voicelab: could not write %s\n", + out.getFullPathName().toRawUTF8()); + return 1; + } + std::printf(" wrote %s\n", out.getFullPathName().toRawUTF8()); + return 0; +} + +} // namespace + +int main(int argc, char *argv[]) { + juce::ScopedJuceInitialiser_GUI juceInit; + + if (argc < 2) { + usage(); + return 1; + } + + Options o; + juce::StringArray files; + o.voice = juce::String(argv[1]).toLowerCase(); + if (o.voice == "-h" || o.voice == "--help") { + usage(); + return 0; + } + + for (int i = 2; i < argc; ++i) { + const juce::String arg(argv[i]); + auto next = [&]() -> juce::String { + return (i + 1 < argc) ? juce::String(argv[++i]) : juce::String(); + }; + + if (arg == "-o") + o.out = juce::File::getCurrentWorkingDirectory().getChildFile(next()); + else if (arg == "--sr") + o.sampleRate = next().getDoubleValue(); + else if (arg == "--seconds") + o.seconds = next().getDoubleValue(); + else if (arg == "--velocity") + o.velocity = (float)next().getDoubleValue(); + else if (arg == "--articulation") + o.articulation = next().getIntValue(); + else if (arg == "--seed") + o.seed = (std::uint32_t)next().getLargeIntValue(); + else if (arg == "--open") + o.open = true; + else if (arg == "--repeats") + o.repeats = next().getIntValue(); + else if (arg == "--spacing") + o.spacing = next().getDoubleValue(); + else if (arg == "--key") + o.keyName = next(); + else if (arg == "--bpm") + o.bpm = next().getIntValue(); + else if (arg == "--bpi") + o.bpi = next().getIntValue(); + else if (arg == "--bars") + o.bars = next().getIntValue(); + else if (arg == "--technique") { + const auto name = next().toLowerCase(); + if (name == "picked") + o.technique = BotVoice::BassTechnique::Picked; + else if (name == "muted") + o.technique = BotVoice::BassTechnique::Muted; + else if (name == "fingered") + o.technique = BotVoice::BassTechnique::Fingered; + else { + std::fprintf(stderr, + "voicelab: technique is fingered, picked or muted\n"); + return 1; + } + } else if (arg == "--instrument") { + const auto name = next().toLowerCase(); + o.instrumentNamed = true; + if (name == "epiano" || name == "piano") + o.instrument = BotVoice::LeadInstrument::EPiano; + else if (name == "guitar") + o.instrument = BotVoice::LeadInstrument::Guitar; + else if (name == "synth") + o.instrument = BotVoice::LeadInstrument::Synth; + else { + std::fprintf(stderr, + "voicelab: instrument is epiano, guitar or synth\n"); + return 1; + } + } else if (arg == "--patch") { + const auto name = next().toLowerCase(); + o.patchNamed = true; + if (name == "strings") + o.patchCharacter = BotVoice::PadCharacter::Strings; + else if (name == "brass") + o.patchCharacter = BotVoice::PadCharacter::Brass; + else if (name == "poly") + o.patchCharacter = BotVoice::PadCharacter::Poly; + else { + std::fprintf(stderr, "voicelab: patch is strings, brass or poly\n"); + return 1; + } + } else if (arg == "--lufs") { + o.matchLufs = true; + o.targetLufs = next().getDoubleValue(); + } else if (arg == "--note") { + if (!parseNote(next(), o.midiNote)) { + std::fprintf(stderr, "voicelab: not a note\n"); + return 1; + } + } else if (arg == "--sweep") { + const auto spec = next(); + const int eq = spec.indexOfChar('='); + if (eq <= 0) { + std::fprintf(stderr, "voicelab: --sweep wants name=lo:hi:count\n"); + return 1; + } + o.sweepParam = spec.substring(0, eq); + const auto parts = + juce::StringArray::fromTokens(spec.substring(eq + 1), ":", ""); + if (parts.size() != 3) { + std::fprintf(stderr, "voicelab: --sweep wants name=lo:hi:count\n"); + return 1; + } + o.sweepLo = parts[0].getDoubleValue(); + o.sweepHi = parts[1].getDoubleValue(); + o.sweepCount = juce::jmax(1, parts[2].getIntValue()); + } else if (arg.startsWithChar('-')) { + std::fprintf(stderr, "voicelab: unknown option %s\n", arg.toRawUTF8()); + return 1; + } else { + files.add(arg); + } + } + + // `file` takes a path rather than being a voice, so it is handled before the + // list of things that can be rendered. + if (o.voice == "file") { + if (files.isEmpty()) { + std::fprintf(stderr, "voicelab: file needs a path\n"); + return 1; + } + int failures = 0; + for (const auto &path : files) + failures += measureFile( + o, juce::File::getCurrentWorkingDirectory().getChildFile(path)); + return failures; + } + + const juce::StringArray known{"kick", "snare", "hat", "bass", + "lead", "pad", "kit", "keys", + "solo", "band", "leadstats"}; + if (!known.contains(o.voice)) { + std::fprintf(stderr, "voicelab: unknown voice %s\n", o.voice.toRawUTF8()); + usage(); + return 1; + } + if (o.sampleRate <= 0.0) { + std::fprintf(stderr, "voicelab: sample rate must be positive\n"); + return 1; + } + + // leadstats: what shape is the line, measured rather than described. + // + // "It leaps oddly" is a real complaint and not a testable one. This counts + // every melodic interval across a sweep of seeds and prints the histogram, + // which turns a judgement about one bar into a number that moves when the + // objective changes. + if (o.voice == "leadstats") { + auto key = MusicalKey::parseName(o.keyName.toStdString()); + if (!key.valid) + key = MusicalKey::parseName("C major"); + + int notes = 0, rests = 0, moves = 0; + long long totalMotion = 0; + int biggest = 0; + std::array hist{}; + int reversals = 0, continuations = 0; + // A repeated note over a chord that CHANGED is a common tone -- the same + // pitch re-heard as a new colour, which is a melodic device. A repeated + // note over the same chord is just standing still. The cost table cannot + // tell them apart, so count them apart. + int repeatSameChord = 0, repeatNewChord = 0; + int stepSameChord = 0, stepNewChord = 0; + // How much of the space between two onsets the note actually fills. Under + // the old rule this was always 1.0 except for a colour note; the shared + // duration model gives a downbeat more room than an off-beat, which is + // articulation rather than note choice and is worth seeing separately. + long long fillGap = 0, fillHeld = 0; + int shortened = 0, sounded = 0; + + const int seeds = juce::jmax(1, o.repeats); + for (int sd = 0; sd < seeds; ++sd) { + auto s = BotBand::defaults(key, o.bpm, o.bpi, o.sampleRate, + o.seed + (std::uint32_t)sd); + s.articulation = o.articulation; + + // The line is continuous across intervals, so the interval between the + // last note of one and the first of the next is a real melodic move and + // is counted as one. + const auto layout = Harmony::layoutChart(s.chart, s.bpi); + int last = -1, lastMove = 0, lastChordRoot = -999, lastChordTones = -1; + for (int interval = 0; interval < o.bars; ++interval) { + int step = -1; + for (int n : BotBand::leadLine(s, interval)) { + ++step; + if (n < 0) { + ++rests; + continue; + } + const auto &ch = Harmony::chordAtStep(layout, step); + int tonesKey = ch.toneCount; + for (int t = 0; t < ch.toneCount; ++t) + tonesKey = tonesKey * 31 + ch.tones[(size_t)t]; + const bool chordChanged = + (ch.root != lastChordRoot || tonesKey != lastChordTones); + { + const auto lineNow = BotBand::leadLine(s, interval); + size_t nx = (size_t)step + 1; + while (nx < lineNow.size() && lineNow[nx] < 0) + ++nx; + const int beatSamples = (int)(o.sampleRate * 60.0 / o.bpm); + const int eighth = beatSamples / 2; + const int gap = (int)(nx - (size_t)step) * eighth; + const auto sd = BotBand::toSoundingChord(ch); + const auto tr = chalkwalk::music::tierOf(BotBand::toKeySig(s.key), + ((n % 12) + 12) % 12, sd); + const int want = chalkwalk::music::holdIn( + chalkwalk::music::holdTicks( + BotBand::metricStrength(step, s.bpi), tr), + beatSamples); + const int held = + chalkwalk::music::articulate(want, gap, s.articulation); + fillGap += gap; + fillHeld += held; + ++sounded; + if (held < gap) + ++shortened; + } + lastChordRoot = ch.root; + lastChordTones = tonesKey; + + ++notes; + if (last >= 0) { + const int d = n - last; + ++moves; + totalMotion += std::abs(d); + biggest = juce::jmax(biggest, std::abs(d)); + hist[(size_t)juce::jmin(63, std::abs(d))]++; + if (d != 0 && lastMove != 0) { + if ((d > 0) == (lastMove > 0)) + ++continuations; + else + ++reversals; + } + if (d != 0) + lastMove = d; + if (d == 0) { + if (chordChanged) + ++repeatNewChord; + else + ++repeatSameChord; + } else { + if (chordChanged) + ++stepNewChord; + else + ++stepSameChord; + } + } + last = n; + } + } + } + + std::printf( + "leadstats %s %d bpm %d bpi seeds %u..%u %d intervals each\n", + o.keyName.toRawUTF8(), o.bpm, o.bpi, (unsigned)o.seed, + (unsigned)(o.seed + (std::uint32_t)seeds - 1), o.bars); + std::printf(" notes %d rests %d moves %d\n", notes, rests, moves); + if (moves > 0) { + std::printf(" mean |interval| %.2f semitones\n", + (double)totalMotion / moves); + std::printf(" largest %d\n", biggest); + int stepwise = 0, leaps = 0, wide = 0; + for (size_t d = 0; d < hist.size(); ++d) { + if (d <= 2) + stepwise += hist[d]; + else if (d <= 7) + leaps += hist[d]; + else + wide += hist[d]; + } + std::printf(" stepwise (<=2) %5d %5.1f%%\n", stepwise, + 100.0 * stepwise / moves); + std::printf(" small leap (3-7) %5d %5.1f%%\n", leaps, + 100.0 * leaps / moves); + std::printf(" wide (>=8) %5d %5.1f%%\n", wide, + 100.0 * wide / moves); + const int repeats = repeatSameChord + repeatNewChord; + std::printf(" repeated notes %5d %5.1f%% of which %d over a NEW " + "chord (%.1f%%) and %d over the same (%.1f%%)\n", + repeats, 100.0 * repeats / moves, repeatNewChord, + repeats ? 100.0 * repeatNewChord / repeats : 0.0, + repeatSameChord, + repeats ? 100.0 * repeatSameChord / repeats : 0.0); + if (sounded > 0) + std::printf( + " note fills %5.1f%% of the space to the next onset;" + " %d of %d shortened\n", + 100.0 * (double)fillHeld / (double)fillGap, shortened, sounded); + const int chordChanges = repeatNewChord + stepNewChord; + std::printf(" chord changed under %5.1f%% of moves\n", + 100.0 * chordChanges / moves); + if (continuations + reversals > 0) + std::printf(" direction kept %5.1f%% (of %d turns)\n", + 100.0 * continuations / (continuations + reversals), + continuations + reversals); + std::printf(" histogram:\n"); + for (size_t d = 0; d < hist.size(); ++d) + if (hist[d] > 0) + std::printf(" %3d %5d %5.1f%% %s\n", (int)d, hist[d], + 100.0 * hist[d] / moves, + juce::String::repeatedString( + "#", juce::jmax(0, (int)(200.0 * hist[d] / moves))) + .toRawUTF8()); + } + return 0; + } + + if (o.voice == "band") { + if (o.out == juce::File()) + o.out = juce::File::getCurrentWorkingDirectory().getChildFile("band.wav"); + std::printf("band %s %d bpm %d bpi seed %u\n", o.keyName.toRawUTF8(), + o.bpm, o.bpi, (unsigned)o.seed); + std::vector mix, mixR; + renderBandStereo(o, mix, mixR); + matchLoudness(o, mix, &mixR); + report("band (mixed)", mix, o.sampleRate, &mixR); + if (!writeWav(o.out, mix, o.sampleRate, &mixR)) { + std::fprintf(stderr, "voicelab: could not write %s\n", + o.out.getFullPathName().toRawUTF8()); + return 1; + } + std::printf("wrote %s\n", o.out.getFullPathName().toRawUTF8()); + return 0; + } + + if (o.voice == "solo") { + // The lead through the real path, so the instrument, the note choices and + // the ring-on are all the ones the room would hear. + if (o.out == juce::File()) + o.out = juce::File::getCurrentWorkingDirectory().getChildFile("solo.wav"); + + auto key = MusicalKey::parseName(o.keyName.toStdString()); + if (!key.valid) + key = MusicalKey::parseName("C major"); + auto settings = BotBand::defaults(key, o.bpm, o.bpi, o.sampleRate, o.seed); + settings.articulation = o.articulation; + if (o.instrumentNamed) + settings.leadOverride = (int)o.instrument; + + std::printf( + "solo seed %u %s\n", (unsigned)o.seed, + BotVoice::leadInstrumentName(BotBand::leadInstrument(settings))); + + const int n = (int)(o.sampleRate * 60.0 / o.bpm) * o.bpi; + std::vector mix; + for (int interval = 0; interval < o.bars; ++interval) { + std::vector one((size_t)n, 0.0f); + BotBand::renderInterval(BotBand::Voice::Lead, settings, interval, + one.data(), n); + mix.insert(mix.end(), one.begin(), one.end()); + } + matchLoudness(o, mix, nullptr); + report("solo", mix, o.sampleRate); + if (!writeWav(o.out, mix, o.sampleRate)) { + std::fprintf(stderr, "voicelab: could not write %s\n", + o.out.getFullPathName().toRawUTF8()); + return 1; + } + std::printf("wrote %s\n", o.out.getFullPathName().toRawUTF8()); + return 0; + } + + if (o.voice == "kit" || o.voice == "keys") { + const bool isKeys = o.voice == "keys"; + if (o.out == juce::File()) + o.out = juce::File::getCurrentWorkingDirectory().getChildFile(o.voice + + ".wav"); + + if (isKeys) { + auto key = MusicalKey::parseName(o.keyName.toStdString()); + if (!key.valid) + key = MusicalKey::parseName("C major"); + + // --patch here means "find me a seed whose keyboard player brought + // that", rather than overriding what the seed chose. The band's patch + // has to stay a pure function of its seed or the audition would be of a + // sound the room can never produce. + if (o.patchNamed) + for (int tries = 0; tries < 64; ++tries) { + const auto probe = + BotBand::defaults(key, o.bpm, o.bpi, o.sampleRate, o.seed); + if (BotBand::keysPatch(probe).character == o.patchCharacter) + break; + o.seed += 1u; + } + + auto settings = + BotBand::defaults(key, o.bpm, o.bpi, o.sampleRate, o.seed); + + settings.articulation = o.articulation; + const auto patch = BotBand::keysPatch(settings); + std::printf("keys seed %u patch %s: detune %.1f cents, cutoff %.1f " + "partials, res %.2f, env x%.1f, attack %.0f ms, drive %.2f\n", + (unsigned)o.seed, BotVoice::padCharacterName(patch.character), + patch.detuneCents, patch.cutoffPartials, patch.resonance, + patch.envAmount, 1000.0 * patch.attackSeconds, patch.drive); + } + + std::vector l, r; + renderVoice(o, isKeys ? BotBand::Voice::Keys : BotBand::Voice::Drums, l, r); + matchLoudness(o, l, &r); + report(isKeys ? "keys (with chorus)" : "kit (with room)", l, o.sampleRate, + &r); + if (!writeWav(o.out, l, o.sampleRate, &r)) { + std::fprintf(stderr, "voicelab: could not write %s\n", + o.out.getFullPathName().toRawUTF8()); + return 1; + } + std::printf("wrote %s\n", o.out.getFullPathName().toRawUTF8()); + return 0; + } + + if (o.sweepParam.isNotEmpty()) { + // A directory of renders and a manifest, so a sweep can be listened to in + // order and read as numbers afterwards. + if (o.out == juce::File()) + o.out = juce::File::getCurrentWorkingDirectory().getChildFile("sweep"); + o.out.createDirectory(); + + juce::StringArray manifest; + for (int i = 0; i < o.sweepCount; ++i) { + const double t = + o.sweepCount == 1 ? 0.0 : (double)i / (double)(o.sweepCount - 1); + const double value = o.sweepLo + t * (o.sweepHi - o.sweepLo); + + Options step = o; + if (o.sweepParam == "velocity") + step.velocity = (float)value; + else if (o.sweepParam == "note") + step.midiNote = (int)std::lround(value); + else if (o.sweepParam == "seed") + step.seed = (std::uint32_t)std::lround(value); + else { + std::fprintf(stderr, "voicelab: cannot sweep %s\n", + o.sweepParam.toRawUTF8()); + return 1; + } + + const auto buf = renderOne(step); + const auto name = + o.voice + "-" + o.sweepParam + "-" + juce::String(value, 3) + ".wav"; + const auto file = o.out.getChildFile(name); + if (!writeWav(file, buf, o.sampleRate)) { + std::fprintf(stderr, "voicelab: could not write %s\n", + file.getFullPathName().toRawUTF8()); + return 1; + } + report(name, buf, o.sampleRate); + manifest.add( + name + " " + o.sweepParam + "=" + juce::String(value, 3) + + " peak " + + juce::String(AudioMeasure::peak(buf.data(), (int)buf.size()), 3) + + " rms " + + juce::String(AudioMeasure::rms(buf.data(), (int)buf.size()), 4)); + } + + const auto index = o.out.getChildFile("index.txt"); + index.replaceWithText(manifest.joinIntoString("\n") + "\n"); + std::printf("wrote %d files, manifest at %s\n", o.sweepCount, + index.getFullPathName().toRawUTF8()); + return 0; + } + + if (o.out == juce::File()) + o.out = + juce::File::getCurrentWorkingDirectory().getChildFile(o.voice + ".wav"); + + auto buf = renderOne(o); + matchLoudness(o, buf, nullptr); + report(o.voice, buf, o.sampleRate); + if (!writeWav(o.out, buf, o.sampleRate)) { + std::fprintf(stderr, "voicelab: could not write %s\n", + o.out.getFullPathName().toRawUTF8()); + return 1; + } + std::printf("wrote %s\n", o.out.getFullPathName().toRawUTF8()); + return 0; +} diff --git a/website/docs/chat-and-voting.md b/website/docs/chat-and-voting.md index d1da06d..4f5a5f4 100644 --- a/website/docs/chat-and-voting.md +++ b/website/docs/chat-and-voting.md @@ -45,10 +45,68 @@ does for chords: it sends an ordinary chat message in a tagged form. - `/key Dm` sends `[key: D minor]`, which Antiphon shows in the header and every other client shows as plain text. Nothing is invented on the wire and no other client has to cooperate. -- A line like `| Dm7 | G7 | Bb | Am7 |` is recognised as a chord progression and - displayed as one. +- `/chords Am F C G` sends `| Am | F | C | G |`, which Jamtaba understands and + Antiphon draws. -Keys are read **only** from that tagged form, never from free chat text. +**Somebody on another client can set the key too**, in either of two ways: type +the tag `[key: D minor]` by hand, or put `/key D minor` at the **start** of a +line. Other clients pass an unknown slash command straight through as chat, so +the second works everywhere and is easier to type. + +The two forms exist because neither can do the other's job. The tag is matched +anywhere in a line, so it can ride in the room topic -- which matters, because +NINJAM replays no chat to somebody who joins later, and the topic is the only +room state that persists. The `/key` form is matched only at the start of a +line, which is what lets anyone *talk about* it: a sentence mentioning +`/key D minor` in passing does not change the key, where a sentence mentioning +the tag would. + +Keys are read **only** from those two forms, never from free chat text. Guessing at prose is how you end up with a header confidently announcing that the room is playing in "I am tired" -- which is a real entry in another client's own test suite. + +### Reading the chart + +An announced chart appears as a row of chord names just above the phase bar, +each one where its change actually falls in the interval. The moving bar sweeps +through them, so you can see the next chord coming rather than being told it +exists after it arrives. The chord sounding now is the bright one, and the same +chart in roman numerals sits at the right of the row above. + +Only a chart somebody announced is ever drawn. If nobody has said what you are +playing over, the row is not there. + +**Bars matter.** `| Dm7 | C# Csus |` is two bars, and the second one holds two +chords -- so Dm7 lasts twice as long as either of them. Writing the same three +chords as `| Dm7 | C# | Csus |` gives each of them a third of the interval, +which is a different piece of music. + +### Degrees, if you think that way + +`/chords` also takes roman numerals and scale degrees, once a key is set: + +- `/chords ii V I` in C major sends `| Dm7 | G7 | Cmaj7 |` +- `/chords i VI III VII` in D minor sends `| Dm | Bb | F | C |` +- `/chords 1 4 b6` sends `| C | F | Ab |` + +Case carries the quality -- `IV` is major, `iv` is minor -- and a plain number +takes whatever chord the key already has on that degree. Your client works the +chords out and sends the ordinary chord names, so nobody else in the room needs +to know you typed it that way. + +Each chord is spelled against the key rather than the whole chart being spelled +one way. D major takes sharps, so its chords are written with sharps -- but a +flattened second in it is still `Eb7`, because that is what the notation wants +however the key signature reads. + +### The key nobody said + +If someone announces a chart and no key has been set, Antiphon works out what +key the chords suggest and offers it on the chip under the chat, next to a +**Set key** button. Clicking it announces the key the same way `/key` would. + +It only offers when the chords are actually decisive. `| Dm7 | G7 | Cmaj7 |` can +only be C major; `| Am | F | C | G |` is equally at home in C major and A minor, +so nothing appears -- a suggestion that is wrong half the time is worse than no +suggestion.