diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 7f74785..7062365 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -7,6 +7,7 @@ "platformio.platformio-ide" ], "unwantedRecommendations": [ - "ms-vscode.cpptools-extension-pack" + "ms-vscode.cpptools-extension-pack", + "pioarduino.pioarduino-ide" ] } diff --git a/README.md b/README.md index b7829e1..869042b 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ Samplotron is a standalone hardware sampler played with an external MIDI control ![Samplotron hardware sampler](Samplotron.jpg) -The current photos show an earlier version played only through external MIDI. Photos of the version with the built-in keypad are coming soon. +## Watch Samplotron in action -[Why I built my own sampler — full article on Medium](https://medium.com/@KubaPisze/i-built-my-own-sampler-to-fit-my-needs-217493f4067c) +[![Watch Samplotron in action — play the video on YouTube](docs/assets/youtube-preview.png)](https://www.youtube.com/watch?v=keyMwPUFDeM) For a walkthrough with screen photos, see the [musician's manual](docs/manual.md). Firmware binaries are available in the [latest main release](https://github.com/jakubthedeveloper/Samplotron/releases/tag/main-latest). @@ -76,7 +76,7 @@ Pin assignments are defined in [include/pins.h](include/pins.h); keypad note map The audio output uses **one headphones-out channel (mono)** through a potentiometer wired as a voltage divider: one outer lug to the headphone signal, the other outer lug to ground, and the wiper to the output jack tip. Connect the jack sleeve to ground. At the headphone socket, use tip (L) or ring (R) relative to sleeve (ground), leaving the other channel unconnected. Both channels carry the same mono signal; -**Good grounding is essential:** use an electrically continuous metal enclosure, bring all device-side grounds to one star point, and bond it securely to the enclosure at a dedicated point. Do not rely on a jack or potentiometer mounting nut for the ground connection. +**The output jack sleeve must be connected to both headphone output ground and the ESP32 GND pin.** Bring all device-side grounds to one star point, but keep them electrically isolated from the enclosure. Do not connect ground to the enclosure, including through jack or potentiometer mounting hardware: this can introduce OLED interference into the audio output. For normal operation, use the current build's dedicated power path: **9 V jack → step-down to 5 V → B0505S-3WR3 isolator → the AudioKit board's BAT connector**. The builder recommends good-quality guitar-pedal supplies over USB power. USB is still used for firmware programming. See [audio wiring, grounding and power](docs/documentation.md#audio-output-grounding-and-power) for the divider connections and isolated ground routing. @@ -94,7 +94,24 @@ During boot, every loaded library entry (up to 32, including unassigned samples) No configuration file is required for first boot; without one, the device starts with no assignments, one-shot playback, and the default RAM budget. -The repository includes a batch conversion command, requiring `ffmpeg` and Make: +On Linux, install FFmpeg using your distribution's package manager (for example, `sudo apt install ffmpeg` on Debian/Ubuntu). Run this in Bash, setting `samples_dir` to the directory containing your WAV files: + +```bash +samples_dir="/path/to/your samples" +( + cd -- "$samples_dir" || exit 1 + mkdir -p -- samplotron || exit 1 + for file in *.[wW][aA][vV]; do + [ -f "$file" ] || continue + ffmpeg -nostdin -n -i "./$file" -map 0:a:0 -ac 1 -ar 44100 \ + -c:a pcm_s16le -map_metadata -1 "samplotron/${file%.*}.wav" || exit 1 + done +) +``` + +This converts WAV files directly in the selected directory to uncompressed PCM16, 44.1 kHz, mono. Originals are preserved; output goes into its `samplotron` subdirectory, ready to copy to `/samples` on the SD card. Existing output files are not overwritten. This command does not trim silence or normalize volume. + +The repository also includes a batch conversion command, requiring `ffmpeg` and Make: ```bash make convert-samples SAMPLES_DIR=/path/to/sample-copies @@ -131,6 +148,8 @@ With Make installed, the equivalents are `make build-main` and `make upload-main To flash without building, download `firmware.bin`, `bootloader.bin`, `partitions.bin`, and `boot_app0.bin` from the same [main-latest release](https://github.com/jakubthedeveloper/Samplotron/releases/tag/main-latest), then follow the [prebuilt firmware instructions](docs/documentation.md#flashing-prebuilt-firmware). +Save failures appear on the OLED as compact codes such as `SAVE E15`. See [save error codes](docs/save-errors.md) for their meanings and diagnosis without a serial monitor. + ### Tests Run the native tests without an ESP32 connected: diff --git a/Samplotron.jpg b/Samplotron.jpg index 62e978c..88d31ac 100644 Binary files a/Samplotron.jpg and b/Samplotron.jpg differ diff --git a/docs/assets/youtube-preview.png b/docs/assets/youtube-preview.png new file mode 100644 index 0000000..097786d Binary files /dev/null and b/docs/assets/youtube-preview.png differ diff --git a/docs/assets/youtube-preview.svg b/docs/assets/youtube-preview.svg new file mode 100644 index 0000000..e37db6b --- /dev/null +++ b/docs/assets/youtube-preview.svg @@ -0,0 +1,15 @@ + + Watch Samplotron in action on YouTube + Video thumbnail showing Samplotron and an oscilloscope, with a play button. Click to watch on YouTube. + + + + + + + Samplotron in action + See it. Hear it. Play it. + Watch on YouTube + + + diff --git a/docs/documentation.md b/docs/documentation.md index 5d0117f..875928b 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -62,12 +62,13 @@ The current hardware build takes **one channel of headphones out (mono)** throug | One headphone channel: tip (L) or ring (R) | One outer potentiometer lug | | Potentiometer wiper | Mono output jack tip | | Other outer potentiometer lug | Device ground star point | -| Headphone ground / board GND | Device ground star point | +| Headphone output ground (sleeve) | Device ground star point | +| ESP32 GND pin | Device ground star point | | Mono output jack sleeve | Device ground star point | The potentiometer is a voltage divider, not a two-terminal series resistor. At maximum volume its wiper reaches the signal-side lug; at minimum it reaches the ground-side lug. -**Grounding and enclosure continuity are essential.** Use a metal enclosure with electrical continuity across all its parts. Bring all device-side ground connections together in one star point, including board/headphone ground, the potentiometer ground lug, output jack sleeve and isolated power return. Bond that point to the enclosure through a dedicated, secure connection with good metal-to-metal contact. Do not use a jack or potentiometer mounting nut as the ground connection. Route a ground wire to the jack sleeve and the potentiometer ground lug rather than relying on mechanical mounting. +**The output jack sleeve must be connected to both headphone output ground and the ESP32 GND pin.** Bring all device-side ground connections together in one star point, including headphone ground, ESP32 GND, the potentiometer ground lug, output jack sleeve and isolated power return. **Do not connect this ground to the enclosure: doing so can introduce OLED interference into the audio output.** Route ground wires to the jack sleeve and the potentiometer ground lug, and ensure that jack and potentiometer mounting hardware does not electrically connect ground to the enclosure. For normal operation, the builder recommends a dedicated supply jack instead of USB power. The tested power arrangement for this hardware revision is: diff --git a/docs/manual.md b/docs/manual.md index 6b5738b..76ebeed 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -4,7 +4,7 @@ This guide focuses on making music with Samplotron: loading sounds, mapping them Connect the mono output jack to your mixer and start at a low monitoring volume. Inside Samplotron, **one headphones-out channel feeds a potentiometer used as a voltage divider**: one outer lug receives the headphone signal, the other connects to ground, and the wiper feeds the output jack tip. The jack sleeve connects to ground. Do not use the AudioKit's separate L/R speaker terminals: their Class-D amplifiers produce a switching, speaker-level signal unsuitable for this connection. Line-in and microphones are disabled; load samples from the SD card. -For a quiet output, all device-side grounds meet at one star point, bonded to a metal enclosure with full electrical continuity through a dedicated, secure contact. A jack or potentiometer mounting nut must not serve as that ground connection. +The output jack sleeve must be connected to both headphone output ground and the ESP32 GND pin. All device-side grounds meet at one star point and must remain electrically isolated from the enclosure, including at jack and potentiometer mounts. Connecting ground to the enclosure can introduce OLED interference into the audio output. The current build uses **9 V input → step-down to 5 V → B0505S-3WR3 isolator → the AudioKit board's BAT connector**, allowing good-quality guitar-pedal supplies. Prefer this dedicated power input over USB for normal operation; use the programming USB port for firmware updates. See the [build wiring](documentation.md#audio-output-grounding-and-power) for grounding and connector details. diff --git a/docs/measurements/calib-2026-09-10.json b/docs/measurements/calib-2026-09-10.json deleted file mode 100644 index c714645..0000000 --- a/docs/measurements/calib-2026-09-10.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "fundamental_hz": 1000.1183438584645, - "fundamental_peak_dbfs": -36.58346364344135, - "steady_rms_dbfs": -39.59374142868431, - "harmonics_2_to_10_thd_percent": 0.05304409215340069, - "residual_rms_dbfs": -80.50640124104365, - "dc": 1.4651689900654404e-05, - "source_to_recording_db": -24.58346364344135, - "file": "calib.wav", - "sha256": "70ed34a3210a70e33dbd2975cd6d7e64aade63a51f061428b1c5fae6d5297290", - "sample_rate_hz": 44100, - "pcm_bits": 24, - "channels": 1, - "duration_seconds": 7.1893424036281175, - "analysis_window_seconds": [ - 3, - 5 - ], - "reference_peak_dbfs": -12, - "reported_mixer_gain_db": 40 -} diff --git a/docs/measurements/calib-2026-09-10.md b/docs/measurements/calib-2026-09-10.md deleted file mode 100644 index 18a5afd..0000000 --- a/docs/measurements/calib-2026-09-10.md +++ /dev/null @@ -1,33 +0,0 @@ -# Calibration recording — 2026-09-10 - -The user recorded the previously generated 1 kHz, -12 dBFS peak probe as `~/Pulpit/calib.wav`, using the same Samplotron settings, ZEDi-10 channel gain +40 dB, and JACK/Reaper at 0 dB. The temporary reference WAV is no longer present in `/tmp`; its known generation parameters are recorded in the [earlier investigation](sample-test-2026-09-09.md). No host audio settings were accessed or changed. - -`calib.wav` is mono PCM24 at 44.1 kHz, lasting 7.189342 s. It contains silence followed by approximately four seconds of tone. Analysis uses the steady 3–5 s portion, excluding onset and EOF. Signed PCM was divided by 8388608; no gain or normalization was applied. - -| Measurement | Result | -| --- | ---: | -| Known source sine peak | -12 dBFS | -| Fitted recorded sine peak | -36.583 dBFS | -| Recorded steady RMS | -39.594 dBFS | -| Source-to-recording digital level difference | -24.583 dB | -| Fitted fundamental frequency | 1000.118 Hz | -| Estimated THD, harmonics 2–10 | 0.053% | -| Residual RMS after fitting DC and harmonics 1–10 | -80.506 dBFS | -| Idle RMS, representative 0.25 s windows | about -85.3 dBFS | - -The peak was obtained by least-squares sine/cosine fitting, with frequency optimized between 999.95 and 1000.3 Hz. THD is the root-sum-square amplitude of fitted harmonics 2–10 divided by fundamental amplitude. The residual includes noise, clock variation, unmodeled distortion and fitting error; it is not an independently calibrated noise measurement. The approximately 118 ppm frequency difference can reflect the combined playback/recording clocks. - -The stable recorded tone has no waveform clipping evident in this analysis. Its level is approximately 17 times smaller in normalized PCM amplitude than the source. This establishes substantial attenuation in the complete source-to-recording transfer, even with the reported input gain, but does not locate it in the codec, output assembly, cable or mixer routing. - -Do not add the reported +40 dB directly to the PCM level difference and label the result a measured analog loss: source DAC voltage calibration, mixer input path and ADC full-scale mapping have not been measured. The ZEDi-10 specifies 18 dB USB headroom above nominal and provides multiple recording-source routes. [Manufacturer specifications](https://www.allen-heath.com/content/uploads/2023/06/ZEDi-10-Technical-Datasheet-1.pdf). - -Firmware already uses unity gain for a solo voice at VOL=100 outside the brief boundary ramps, with DAC and analog volume set to 0 dB and checked by register readback at boot. Existing regression tests cover the digital unity path. Increasing digital gain without identifying the loss would push near-full-scale source material into the limiter. No firmware gain change was made in response to this recording. - -The direct-headphone comparison was subsequently supplied as `kalib2.wav`; see [its analysis](kalib2-2026-09-10.md). - -The source recording was not modified. Numerical results and the recording SHA256 are stored in [calib-2026-09-10.json](calib-2026-09-10.json). Uninterrupted playback was already confirmed by the user separately. The later output repair is recorded below. - - -## Hardware repair confirmed — 2026-09-10 - -The user repaired the audio output. The final build uses one headphone channel through a potentiometer voltage divider, with a common device ground star and a dedicated bond to a continuous metal enclosure. Normal power is 9 V input, step-down to 5 V, B0505S-3WR3 isolation and this AudioKit board's BAT connector. The low-level investigation is closed based on the user's repair confirmation; the measurements above describe the earlier setup. No firmware gain increase was needed. See [current wiring](../documentation.md#audio-output-grounding-and-power). diff --git a/docs/measurements/kalib2-2026-09-10.json b/docs/measurements/kalib2-2026-09-10.json deleted file mode 100644 index 956508b..0000000 --- a/docs/measurements/kalib2-2026-09-10.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "file": "kalib2.wav", - "sha256": "4db9c890edef2fb4476776294d2273e47a1bf1153dab3a871fd91bc0ce81d0fd", - "sample_rate_hz": 44100, - "pcm_bits": 24, - "channels": 1, - "duration_seconds": 6.250657596371882, - "analysis_window_seconds": [ - 2, - 4 - ], - "fundamental_hz": 1000.1180520276966, - "fundamental_peak_dbfs": -13.468860188413856, - "steady_rms_dbfs": -16.479054971362075, - "harmonics_2_to_10_thd_percent": 0.00441074446408979, - "residual_rms_dbfs": -75.97294664366251, - "mixer_gain_db": null, - "connection": "direct headphone output; exact wiring not yet confirmed", - "recorded_level_difference_vs_calib_db": 23.114603455027492 -} diff --git a/docs/measurements/kalib2-2026-09-10.md b/docs/measurements/kalib2-2026-09-10.md deleted file mode 100644 index a611be0..0000000 --- a/docs/measurements/kalib2-2026-09-10.md +++ /dev/null @@ -1,25 +0,0 @@ -# Direct headphone calibration — 2026-09-10 - -The user supplied `~/Pulpit/kalib2.wav`, identifying it as a recording directly from the headphone socket. The input file was not modified. It is mono PCM24, 44.1 kHz, 6.250658 s. At the time of analysis, the new recording's mixer gain and precise headphone-to-mixer wiring have not been confirmed; both were requested. - -The 2–4 s steady segment was analyzed with the same normalized-PCM least-squares method as [calib.wav](calib-2026-09-10.md): optimized fundamental frequency, DC, and sine/cosine terms for harmonics 1–10. - -| Measurement | calib.wav, previous output | kalib2.wav, direct headphone socket | -| --- | ---: | ---: | -| Fundamental peak | -36.583 dBFS | -13.469 dBFS | -| Steady RMS | -39.594 dBFS | -16.479 dBFS | -| Estimated THD, harmonics 2–10 | 0.053% | 0.0044% | -| Residual RMS after fitted harmonics | -80.506 dBFS | -75.973 dBFS | - -The direct recording is **23.115 dB higher**, or approximately **14.31 times** the normalized amplitude. Its tone is stable, with no evident waveform clipping in the analyzed segment. Its fitted frequency is 1000.118 Hz. Relative THD/noise comparisons are affected by the large difference in recorded level; these measurements alone do not assign distortion to an individual component. - -**Conditional conclusion:** if the same source, Samplotron volume, mixer input/gain, routing and recording settings were used, the comparison localizes approximately 23.1 dB of attenuation to the bypassed output assembly or its connections (volume control, jack/wiring). It does not distinguish these elements individually. If input gain changed, subtract the new-minus-old gain difference from the measured 23.1 dB before estimating bypassed-path attenuation. - -Do not interpret the -13.47 dBFS recording as an absolute headphone voltage or proof that the complete analog gain is correct: the recording contains the mixer's gain and ADC scaling. Both headphone channels carry the same mono signal in phase. The final wiring uses one channel relative to headphone ground; see the repair confirmation below. - -No firmware or gain settings were changed. The original comparison was conditional on unchanged settings; the user subsequently repaired the output. Numerical results and the input SHA256 are in [kalib2-2026-09-10.json](kalib2-2026-09-10.json). - - -## Hardware repair confirmed — 2026-09-10 - -The user repaired the audio output. The final build uses one headphone channel through a potentiometer voltage divider, with a common device ground star and a dedicated bond to a continuous metal enclosure. Normal power is 9 V input, step-down to 5 V, B0505S-3WR3 isolation and this AudioKit board's BAT connector. The low-level investigation is closed based on the user's repair confirmation; the measurements above describe the earlier setup. No firmware gain increase was needed. See [current wiring](../documentation.md#audio-output-grounding-and-power). diff --git a/docs/measurements/sample-test-2026-09-09.md b/docs/measurements/sample-test-2026-09-09.md deleted file mode 100644 index 630eb0b..0000000 --- a/docs/measurements/sample-test-2026-09-09.md +++ /dev/null @@ -1,55 +0,0 @@ -# Recording investigation: sample-test.wav, 2026-09-09 - -User setup: one headphone channel through the original output assembly, Allen & Heath ZEDi-10, JACK and Reaper at 0 dB. User reports maximum Samplotron volume and +40 channel gain. Host audio settings were not inspected or changed. - -Inputs supplied on the desktop: - -- `sample-test.wav`: 27 s, 44.1 kHz, mono PCM24; SHA256 `cbf1e5763b5c22b7dd55c6b300e2f8d5d604af59cad6ebc512ffcb996e8c4c47`. -- `amen.wav`: 11.162789 s, 44.1 kHz, mono PCM16; SHA256 `49fa405e2aaf21a25d34c5dd8483c9c4dfb0eea9a0a239b6d5dabb50274fe3f3`. - -Levels were calculated from signed PCM normalized to its full-scale integer range, without normalization or gain processing. RMS includes all frames in each selected interval. - -| Signal / interval | Peak dBFS | RMS dBFS | -| --- | ---: | ---: | -| Original amen.wav | -0.080 | -17.690 | -| Recorded drum solo, 1.15–12.31 s | -28.421 | -46.057 | -| Recorded overlap, 21–27 s | -21.682 | -36.392 | -| Recorded idle, 18–21 s | -74.429 | -85.744 | - -The near-full-scale source rules out a quietly prepared drum sample. The solo peak difference is about 28.3 dB between source PCM and recorded PCM; it is not a calibrated voltage measurement or a measurement of any one component's loss. Mixer input gain, routing and converter headroom belong to the complete transfer path. The ZEDi-10 specifies 18 dB USB headroom above nominal, so a nominal analog meter indication is not 0 dBFS. Its line gain range reaches +40 dB. [Manufacturer specifications](https://www.allen-heath.com/content/uploads/2023/06/ZEDi-10-Technical-Datasheet-1.pdf). - -Large one-frame steps concentrate in the overlaps. Examples: 23.145850, 23.198095, 23.294966, 23.444785, 23.494127, 23.540476, 25.388503, 25.438549, 25.486508 and 26.079932 s. These were selected with absolute sample difference >0.023 FS and a 300-frame minimum separation. That threshold is a locator for this recording, not a general-purpose click detector. Many steps are spaced roughly 50 ms apart and occur during sustained overlap, rather than only at note or file boundaries. - -Local normalized cross-correlation of a 6 kHz high-pass version against the source was used to track drum timing while suppressing the lower-frequency added sounds. Example matches (20 ms windows, local search around the expected source position): - -| Recording time | Source position | Correlation | Recording minus source time | -| ---: | ---: | ---: | ---: | -| 22.000 s | 0.900159 s | 0.8602 | 21.099841 s | -| 23.000 s | 1.900272 s | 0.9802 | 21.099728 s | -| 25.500 s | 4.331293 s | 0.9806 | 21.168707 s | -| 26.000 s | 4.800113 s | 0.8600 | 21.199887 s | - -The increase is consistent with roughly 0.1 s of lost timing continuity by 26 s. Repeated drum patterns, analog filtering and independent recording/playback clocks limit exact alignment; this does not identify a specific DMA or SD failure by itself. Short local matches around 23.1–23.35 s also show stepwise lag increases on the order of one 128-frame DMA descriptor. Repeated or interrupted transport is a stronger hypothesis than file-edge clicks for this recording. Neither CPU deadlines nor physical underrun counters were measured on the ESP32. - -## Changes prepared for another device test - -The ESP32 output previously called `i2s_channel_write()` for each four-byte stereo frame. `StableAudioOutputI2S` now stages 128 frames, matching the existing DMA descriptor size, and submits 512 bytes per call when possible. Partial writes retain their exact unsent byte suffix; a full queue returns backpressure without advancing the rejected input. Continuous idle silence completes partial tail blocks. This uses about 0.5 KiB of staging memory and adds up to 2.9 ms of buffering. It reduces API overhead, but does not move SD reads to another task or certify real-time performance on hardware. The driver itself performs queue/semaphore work per write. [ESP-IDF 5.5.4 implementation](https://github.com/espressif/esp-idf/blob/v5.5.4/components/esp_driver_i2s/i2s_common.c). - -New transport regressions check exact signed stereo packing at unity, one driver call per full DMA block, partial-byte writes, timeouts, and a clock that keeps consuming samples during processing and driver calls. The timing model explicitly assumes 19 us processing per frame and 6 us API overhead per call: the single-frame negative control underruns while the block writer does not. Those costs are synthetic, not claimed ESP32 measurements. The actual ESP32 adapter branch is compiled against a fake I2S driver in this suite. - -Codec startup now reads back both DAC volume registers and all four analog output level registers, rejecting mismatches before unmute. A successful boot reports `Codec: DAC and analog outputs verified at 0 dB`. This checks register state, not output voltage. The existing 0 dB settings remain; the datasheet documents only up to +4.5 dB analog output gain, which would not explain a loss on the scale observed here. [ES8388 datasheet, sections 6.3.24–27](https://www.boardcon.com/download/ES8388_datasheet.pdf). - -## Follow-up measurement - -A four-second mono PCM16/44.1 kHz 1 kHz probe with -12 dBFS peak and 10 ms boundary fades was generated at `/tmp/samplotron-audio-check/calibration-1k-minus12dBFS.wav`. Its sustained sine RMS is approximately -15 dBFS. Play it at sample volume 100 to obtain a known digital reference; start the external mixer gain low, then record the settings, PFL indication and recorded level. This probe was used for the subsequent calibration comparison. - -Firmware build and all 64 native test cases passed. - -## Device confirmation — 2026-09-10 - -The user flashed the firmware with 128-frame I2S writes and confirmed that the sound interruption disappeared. The reported overlapping-playback issue is therefore resolved in the user’s device test. This is listening feedback, not a measurement of underrun counts or maximum sustainable polyphony. The later audio output repair is recorded below. - - -## Hardware repair confirmed — 2026-09-10 - -The user repaired the audio output. The final build uses one headphone channel through a potentiometer voltage divider, with a common device ground star and a dedicated bond to a continuous metal enclosure. Normal power is 9 V input, step-down to 5 V, B0505S-3WR3 isolation and this AudioKit board's BAT connector. The low-level investigation is closed based on the user's repair confirmation; the measurements above describe the earlier setup. No firmware gain increase was needed. See [current wiring](../documentation.md#audio-output-grounding-and-power). diff --git a/docs/save-errors.md b/docs/save-errors.md new file mode 100644 index 0000000..f9f68cb --- /dev/null +++ b/docs/save-errors.md @@ -0,0 +1,34 @@ +# Save error codes + +The OLED displays `SAVE E15` instead of a long error message. The code stays +visible for 15 seconds. Serial output includes the same code alongside the +detailed failure message. Codes identify the failed stage, not necessarily +the underlying hardware or software cause. + +| Code | Meaning | +| --- | --- | +| E00 | Unknown save failure. | +| E01 | Save callback missing or failed without a more specific code. | +| E02 | Save service not initialized. | +| E03 | Playback could not be stopped before saving. | +| E04 | Could not enqueue the sample preparation request. | +| E05 | Sample preparation reported failure. | +| E06 | Could not allocate the JSON output buffer. | +| E07 | JSON capacity exceeded or serialization length mismatch. | +| E08 | Could not open the temporary file for writing. | +| E09 | Could not configure the SD write buffer. | +| E10 | Incomplete write to the temporary file. | +| E11 | Could not reopen the temporary file for verification. | +| E12 | Could not configure the SD read buffer. | +| E13 | Stored file size differs from the expected size. | +| E14 | Verification read stopped or returned an invalid byte count. | +| E15 | Read-back data differs from the generated JSON. | +| E16 | Could not move the previous configuration to the backup path. | +| E17 | Could not move the verified temporary file to the configuration path. | + +When investigating a failure that disappears with the serial monitor open, +leave the monitor closed, reproduce the failure, and record the OLED code. +An error keeps the settings marked as unsaved so the operation can be retried. + +The numeric assignments in `include/save_diagnostics.h` are stable: new codes +should be appended without renumbering existing ones. diff --git a/docs/tutorial/content.js b/docs/tutorial/content.js index a2df1e5..f6af33a 100644 --- a/docs/tutorial/content.js +++ b/docs/tutorial/content.js @@ -578,7 +578,7 @@ window.SAMPLOTRON_GUIDE = { "color": "#74e3b4" } ], - "note": "Pot divider: one outer lug to headphone L (tip) OR R (ring), the other to ground; wiper to output tip, output sleeve to ground. Leave the other headphone channel unconnected. Join all device-side grounds at one star point and bond it securely to an electrically continuous metal enclosure at a dedicated contact, never through a jack or pot mounting nut. Power this build from a 9 V jack → step-down to 5 V → B0505S-3WR3 isolator → AudioKit BAT; keep the supply-side return isolated from device ground. Prefer a good guitar-pedal supply over USB power; USB remains for programming. Do not use Class-D L/R speaker terminals. Speaker amplifiers, line-in and microphones remain disabled.", + "note": "Pot divider: one outer lug to headphone L (tip) OR R (ring), the other to ground; wiper to output tip, output sleeve to ground. Leave the other headphone channel unconnected. The output jack sleeve must connect to both headphone output ground and the ESP32 GND pin. Join all device-side grounds at one star point, electrically isolated from the enclosure, including at jack and pot mounts. Do not connect ground to the enclosure: this can introduce OLED interference into the audio output. Power this build from a 9 V jack → step-down to 5 V → B0505S-3WR3 isolator → AudioKit BAT; keep the supply-side return isolated from device ground. Prefer a good guitar-pedal supply over USB power; USB remains for programming. Do not use Class-D L/R speaker terminals. Speaker amplifiers, line-in and microphones remain disabled.", "command": "", "duration": 45, "screenCues": [], diff --git a/include/sampler_save_service.h b/include/sampler_save_service.h index d3d4d13..f8003da 100644 --- a/include/sampler_save_service.h +++ b/include/sampler_save_service.h @@ -20,7 +20,7 @@ class SamplerSaveService { bool saveConfiguration() const; private: - bool requestLoaderRebuildAndWait(uint32_t timeoutMs) const; + bool requestLoaderRebuildAndWait() const; Ui *ui_ = nullptr; const SampleLibrary::Catalog *catalog_ = nullptr; diff --git a/include/save_diagnostics.h b/include/save_diagnostics.h new file mode 100644 index 0000000..a72b91e --- /dev/null +++ b/include/save_diagnostics.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +// Stable codes shown as E00..E17 on the OLED; see docs/save-errors.md. +// Save runs synchronously on the UI task, so no cross-task access is needed. +namespace SaveDiagnostics { +enum class Stage : uint8_t { + Unknown = 0, + Callback = 1, + Initialization = 2, + Playback = 3, + LoaderQueue = 4, + Loader = 5, + Memory = 6, + Json = 7, + OpenWrite = 8, + WriteBuffer = 9, + Write = 10, + OpenRead = 11, + ReadBuffer = 12, + FileSize = 13, + Read = 14, + DataMismatch = 15, + Backup = 16, + Rename = 17, +}; + +inline Stage &stage() { + static Stage value = Stage::Unknown; + return value; +} +inline void setStage(Stage value) { stage() = value; } +} // namespace SaveDiagnostics diff --git a/include/ui.h b/include/ui.h index 38bed68..8ecebbf 100644 --- a/include/ui.h +++ b/include/ui.h @@ -56,6 +56,7 @@ class Ui { bool assigningPanic = false; bool showSavedFeedback = false; bool lastSaveSucceeded = true; + uint8_t saveErrorCode = 0; bool hasUnsavedChanges = false; bool midiPulseActive = false; }; diff --git a/src/display_ssd1309.cpp b/src/display_ssd1309.cpp index f1d4be9..a606a10 100644 --- a/src/display_ssd1309.cpp +++ b/src/display_ssd1309.cpp @@ -206,7 +206,9 @@ void DisplaySsd1309::renderMain(const Ui::RenderModel &model, const Ui &ui) { } if (model.showSavedFeedback) { - gDisplay.drawStr(0, 36, model.lastSaveSucceeded ? "Saved" : "Save ERR"); + char feedback[12]; + snprintf(feedback, sizeof(feedback), "SAVE E%02u", static_cast(model.saveErrorCode)); + gDisplay.drawStr(0, 36, model.lastSaveSucceeded ? "Saved" : feedback); } const char *kItemsLibOnly[1] = {"LIB"}; diff --git a/src/sampler_save_service.cpp b/src/sampler_save_service.cpp index a6a9b35..db64912 100644 --- a/src/sampler_save_service.cpp +++ b/src/sampler_save_service.cpp @@ -1,4 +1,5 @@ #include "sampler_save_service.h" +#include "save_diagnostics.h" void SamplerSaveService::begin(Ui *ui, const SampleLibrary::Catalog *catalog, @@ -15,47 +16,58 @@ void SamplerSaveService::begin(Ui *ui, } bool SamplerSaveService::saveConfiguration() const { + SaveDiagnostics::setStage(SaveDiagnostics::Stage::Initialization); if (!ui_ || !catalog_ || !runtime_ || !triggerEngine_ || !loaderCommandQueue_ || !uiStatusQueue_) { + Serial.println("Save: service not initialized"); return false; } + SaveDiagnostics::setStage(SaveDiagnostics::Stage::Playback); if (!triggerEngine_->waitForIdle(3000)) { // Save should be reliable even if a loop is currently active. - triggerEngine_->panicAll(); + if (!triggerEngine_->panicAll()) { + Serial.println("Save: could not enqueue playback stop"); + return false; + } if (!triggerEngine_->waitForIdle(1500)) { - + Serial.println("Save: playback did not stop"); return false; } } runtime_->collectAssignmentsFromUi(*ui_, *catalog_); - if (!requestLoaderRebuildAndWait(5000)) { - + if (!requestLoaderRebuildAndWait()) { return false; } const bool ok = runtime_->saveSettingsToSd(); - + Serial.println(ok ? "Save: configuration saved" : "Save: SD write failed"); return ok; } -bool SamplerSaveService::requestLoaderRebuildAndWait(uint32_t timeoutMs) const { +bool SamplerSaveService::requestLoaderRebuildAndWait() const { + SaveDiagnostics::setStage(SaveDiagnostics::Stage::LoaderQueue); LoaderCommand command; command.type = LoaderCommandType::RebuildPreparedSamples; if (xQueueSend(loaderCommandQueue_, &command, pdMS_TO_TICKS(200)) != pdTRUE) { + Serial.println("Save: loader queue full"); return false; } - const TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeoutMs); - while (xTaskGetTickCount() < deadline) { + // Rebuilding reads samples from SD and has no fixed duration. It cannot be + // cancelled: returning early would resume playback while its RAM is changing + // and leave a stale completion event for the next save. + Serial.println("Save: preparing samples"); + SaveDiagnostics::setStage(SaveDiagnostics::Stage::Loader); + while (true) { UiStatusEvent event; if (xQueueReceive(uiStatusQueue_, &event, pdMS_TO_TICKS(20)) != pdTRUE) { continue; } if (event.source == UiStatusSource::SampleLoader && event.type == UiStatusType::LoaderRebuildCompleted) { + if (!event.success) Serial.println("Save: sample preparation failed"); return event.success; } } - return false; } diff --git a/src/settings_store.cpp b/src/settings_store.cpp index c9ae38f..23d3910 100644 --- a/src/settings_store.cpp +++ b/src/settings_store.cpp @@ -1,10 +1,13 @@ #include "settings_store.h" +#include "save_diagnostics.h" #include #include #include #include +#include +#include namespace { @@ -13,6 +16,7 @@ constexpr const char *kSettingsBackupPath = "/sampler_config.bak.json"; constexpr const char *kSettingsTempPath = "/sampler_config.tmp.json"; constexpr const char *kCurrentVersion = "1.0"; constexpr size_t kSettingsJsonCapacity = 12288; +constexpr size_t kSettingsIoBlockBytes = 512; StaticJsonDocument gSettingsJsonDoc; bool isValidNote(long note) { @@ -52,33 +56,109 @@ void ensureParentDirectoryExists(const char *path) { } bool writeJsonToPath(const char *path, const JsonDocument &doc) { - SD.remove(path); - File file = SD.open(path, FILE_WRITE); + // Serialize before touching SD. Keep the bytes until the closed file has + // been reopened and checked, so verification also detects valid but wrong JSON. + const size_t expectedBytes = measureJsonPretty(doc); + SaveDiagnostics::setStage(SaveDiagnostics::Stage::Memory); + std::unique_ptr payload(new (std::nothrow) char[expectedBytes + 1]); + if (!payload) { + Serial.printf("Save: cannot allocate %u bytes for JSON\n", + static_cast(expectedBytes + 1)); + return false; + } + SaveDiagnostics::setStage(SaveDiagnostics::Stage::Json); + if (serializeJsonPretty(doc, payload.get(), expectedBytes + 1) != expectedBytes) { + Serial.println("Save: JSON serialization length mismatch"); + return false; + } + + SaveDiagnostics::setStage(SaveDiagnostics::Stage::OpenWrite); + File file = SD.open(path, FILE_WRITE); // Truncate any previous temporary file. if (!file) { + Serial.printf("Save: cannot open %s for writing\n", path); return false; } - if (serializeJsonPretty(doc, file) == 0) { + // Limit both stdio buffering and each write to one SD sector. Flushing each + // block prevents stdio from combining them into a multi-sector transfer. + SaveDiagnostics::setStage(SaveDiagnostics::Stage::WriteBuffer); + if (!file.setBufferSize(kSettingsIoBlockBytes)) { + Serial.println("Save: cannot configure SD write buffer"); file.close(); return false; } - - file.println(); - file.flush(); + SaveDiagnostics::setStage(SaveDiagnostics::Stage::Write); + size_t written = 0; + while (written < expectedBytes) { + const size_t remaining = expectedBytes - written; + const size_t requested = remaining < kSettingsIoBlockBytes ? remaining : kSettingsIoBlockBytes; + const size_t sent = file.write( + reinterpret_cast(payload.get() + written), requested); + file.flush(); + if (sent != requested) { + Serial.printf("Save: incomplete write at %u: %u/%u bytes\n", + static_cast(written), static_cast(sent), + static_cast(requested)); + file.close(); + return false; + } + written += sent; + } file.close(); - return true; -} -bool verifyJsonAtPath(const char *path) { - File file = SD.open(path, FILE_READ); + SaveDiagnostics::setStage(SaveDiagnostics::Stage::OpenRead); + file = SD.open(path, FILE_READ); if (!file) { + Serial.printf("Save: cannot reopen %s for verification\n", path); + return false; + } + SaveDiagnostics::setStage(SaveDiagnostics::Stage::ReadBuffer); + if (!file.setBufferSize(kSettingsIoBlockBytes)) { + Serial.println("Save: cannot configure SD read buffer"); + file.close(); + return false; + } + SaveDiagnostics::setStage(SaveDiagnostics::Stage::FileSize); + const size_t storedBytes = file.size(); + if (storedBytes != expectedBytes) { + Serial.printf("Save: stored size mismatch: %u/%u bytes\n", + static_cast(storedBytes), static_cast(expectedBytes)); + file.close(); return false; } - gSettingsJsonDoc.clear(); - const DeserializationError error = deserializeJson(gSettingsJsonDoc, file); + uint8_t buffer[kSettingsIoBlockBytes]; + size_t offset = 0; + while (offset < expectedBytes) { + const size_t remaining = expectedBytes - offset; + const size_t requested = remaining < sizeof(buffer) ? remaining : sizeof(buffer); + SaveDiagnostics::setStage(SaveDiagnostics::Stage::Read); + const size_t received = file.read(buffer, requested); + if (received == 0 || received > requested) { + Serial.printf("Save: verification read failed at %u/%u bytes\n", + static_cast(offset), static_cast(expectedBytes)); + file.close(); + return false; + } + SaveDiagnostics::setStage(SaveDiagnostics::Stage::DataMismatch); + if (memcmp(buffer, payload.get() + offset, received) != 0) { + size_t mismatch = 0; + while (buffer[mismatch] == static_cast(payload[offset + mismatch])) { + ++mismatch; + } + Serial.printf("Save: data mismatch at %u/%u: expected %02X, read %02X\n", + static_cast(offset + mismatch), + static_cast(expectedBytes), + static_cast(static_cast(payload[offset + mismatch])), + static_cast(buffer[mismatch])); + file.close(); + return false; + } + offset += received; + } file.close(); - return !error; + Serial.printf("Save: verified %u bytes\n", static_cast(expectedBytes)); + return true; } } // namespace @@ -226,28 +306,30 @@ bool saveToSd(const SamplerSettings &settings) { entry["playback_mode"] = assignment.loopPlaybackEnabled ? "loop" : "shot"; } - if (!writeJsonToPath(kSettingsTempPath, gSettingsJsonDoc)) { - + SaveDiagnostics::setStage(SaveDiagnostics::Stage::Json); + if (gSettingsJsonDoc.overflowed()) { + Serial.println("Save: configuration exceeds JSON capacity"); return false; } - if (!verifyJsonAtPath(kSettingsTempPath)) { - - SD.remove(kSettingsTempPath); + if (!writeJsonToPath(kSettingsTempPath, gSettingsJsonDoc)) { + // Keep the failed temporary file for inspection; the next save truncates it. return false; } + SaveDiagnostics::setStage(SaveDiagnostics::Stage::Backup); if (SD.exists(kSettingsPath)) { SD.remove(kSettingsBackupPath); if (!SD.rename(kSettingsPath, kSettingsBackupPath)) { - + Serial.println("Save: cannot create configuration backup"); SD.remove(kSettingsTempPath); return false; } } + SaveDiagnostics::setStage(SaveDiagnostics::Stage::Rename); if (!SD.rename(kSettingsTempPath, kSettingsPath)) { - + Serial.println("Save: cannot replace configuration file"); if (SD.exists(kSettingsBackupPath)) { SD.rename(kSettingsBackupPath, kSettingsPath); } diff --git a/src/ui.cpp b/src/ui.cpp index a36bf0e..49398ac 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -1,4 +1,5 @@ #include "ui.h" +#include "save_diagnostics.h" #include @@ -144,12 +145,17 @@ void Ui::update() { if (!saveExecutionArmed_) { saveExecutionArmed_ = true; } else { + SaveDiagnostics::setStage(SaveDiagnostics::Stage::Callback); if (!onSave_) { logNotImplemented("save_configuration_callback_missing"); lastSaveSucceeded_ = false; } else { lastSaveSucceeded_ = onSave_(saveContext_); } + model_.saveErrorCode = static_cast(SaveDiagnostics::stage()); + if (!lastSaveSucceeded_) { + Serial.printf("Save: E%02u\n", static_cast(model_.saveErrorCode)); + } saveRunPending_ = false; saveCompletedPending_ = true; saveCompleteAfterMs_ = millis() + kSavingScreenMinMs; @@ -466,7 +472,7 @@ void Ui::completeSave() { if (lastSaveSucceeded_) { hasUnsavedChanges_ = false; } - saveFeedbackUntilMs_ = millis() + kSaveFeedbackMs; + saveFeedbackUntilMs_ = millis() + (lastSaveSucceeded_ ? kSaveFeedbackMs : 15000UL); markDirty(); } diff --git a/test/test_save_service/test_main.cpp b/test/test_save_service/test_main.cpp new file mode 100644 index 0000000..9303d7f --- /dev/null +++ b/test/test_save_service/test_main.cpp @@ -0,0 +1,100 @@ +#include +#include "sampler_save_service.h" +#include "../support/arduino_stubs.cpp" + +namespace { +int polls, collectCalls, saveCalls, idleCalls; +bool sendOk, rebuildOk, sdOk, idleOk, panicOk; +constexpr int kDelayedPolls = 350; // 7 seconds at the service's 20 ms poll interval. +} + +BaseType_t xQueueSend(QueueHandle_t, const void *, TickType_t) { + return sendOk ? pdTRUE : pdFALSE; +} +BaseType_t xQueueReceive(QueueHandle_t, void *value, TickType_t wait) { + testSetMillis(millis() + wait); + ++polls; + auto &event = *static_cast(value); + if (polls == 1) { + event.source = UiStatusSource::AudioEngine; + event.type = UiStatusType::AudioTaskStarted; + event.success = true; + return pdTRUE; + } + if (polls < kDelayedPolls) return pdFALSE; + event = UiStatusEvent{}; + event.success = rebuildOk; + return pdTRUE; +} + +bool TriggerEngine::waitForIdle(uint32_t) const { ++idleCalls; return idleOk; } +bool TriggerEngine::panicAll() { return panicOk; } +void SamplerRuntime::collectAssignmentsFromUi(const Ui &, const SampleLibrary::Catalog &) { + ++collectCalls; +} +bool SamplerRuntime::saveSettingsToSd() const { + TEST_ASSERT_EQUAL_INT(kDelayedPolls, polls); + ++saveCalls; + return sdOk; +} +#include "../../src/sampler_save_service.cpp" + +void setUp() { + polls = collectCalls = saveCalls = idleCalls = 0; + sendOk = rebuildOk = sdOk = idleOk = panicOk = true; + testSetMillis(0); +} +void tearDown() {} + +bool save() { + Ui ui; + SampleLibrary::Catalog catalog; + SamplerRuntime runtime; + TriggerEngine trigger; + SamplerSaveService service; + int queue; + service.begin(&ui, &catalog, &runtime, &trigger, &queue, &queue); + return service.saveConfiguration(); +} +void test_slow_rebuild_finishes_before_sd_save() { + TEST_ASSERT_TRUE(save()); + TEST_ASSERT_GREATER_THAN_UINT32(5000, millis()); + TEST_ASSERT_EQUAL_INT(kDelayedPolls, polls); + TEST_ASSERT_EQUAL_INT(1, collectCalls); + TEST_ASSERT_EQUAL_INT(1, saveCalls); + polls = 0; + TEST_ASSERT_TRUE(save()); + TEST_ASSERT_EQUAL_INT(kDelayedPolls, polls); + TEST_ASSERT_EQUAL_INT(2, saveCalls); +} +void test_loader_failure_does_not_write_sd() { + rebuildOk = false; + TEST_ASSERT_FALSE(save()); + TEST_ASSERT_EQUAL_INT(0, saveCalls); +} +void test_full_queue_does_not_wait_or_write() { + sendOk = false; + TEST_ASSERT_FALSE(save()); + TEST_ASSERT_EQUAL_INT(0, polls); + TEST_ASSERT_EQUAL_INT(0, saveCalls); +} +void test_sd_failure_is_reported() { + sdOk = false; + TEST_ASSERT_FALSE(save()); + TEST_ASSERT_EQUAL_INT(1, saveCalls); +} +void test_failed_stop_does_not_rebuild() { + idleOk = panicOk = false; + TEST_ASSERT_FALSE(save()); + TEST_ASSERT_EQUAL_INT(0, collectCalls); + TEST_ASSERT_EQUAL_INT(0, polls); +} +int main() { + UNITY_BEGIN(); + RUN_TEST(test_slow_rebuild_finishes_before_sd_save); + RUN_TEST(test_loader_failure_does_not_write_sd); + RUN_TEST(test_full_queue_does_not_wait_or_write); + RUN_TEST(test_sd_failure_is_reported); + RUN_TEST(test_failed_stop_does_not_rebuild); + return UNITY_END(); +}