Tech12 min read

M5Stack StackChan Body Head Touch Pad: Tap vs Stroke, Servo Side Effects

IkesanContents

When I moved my CoreS3 into a StackChan Body, I unplugged the Key unit (U144) and the CO2 sensor from the back.
The Key unit was the mechanical push-to-talk button for my voice chat device, so I needed a replacement.

The StackChan Body box lists a “capacitive touch pad,” so I tried making the robot react when you touch its head.
Wiring it all the way through to the speech recognition and reply APIs felt like too much for one step, so this time it is the body alone: a light tap makes it say “Nandesuka?” (“Yes?”), and a stroke makes it say “Ehehe~” while shaking its head left and right.

Test setup

ItemDetails
HardwareM5Stack CoreS3 + StackChan Body (M5STACKCHAN)
Dev environmentWindows 11 + arduino-cli 1.5.1 + esp32 core 3.3.10
Board (FQBN, which board to build for)esp32:esp32:m5stack_cores3
LibrariesStackChan-BSP 1.1.0 (commit 8d4d6fc), M5Unified 0.2.17
AudioTwo WAV clips pre-generated with Irodori-TTS on my home voice server

StackChan-BSP is M5Stack’s official library for the StackChan Body. BSP stands for board support package, a library that wraps the board’s hardware. It was not in the arduino-cli library index, so I cloned it from GitHub into my libraries folder. NFC and IR are not used here, but the BSP depends on them, so I also installed M5Unit-NFC and IRremoteESP8266. That bumped M5Utility from 0.1.0 to 0.3.0.

The StackChan Body touch pad

According to the official documentation, the top of the body has a 3-zone touch pad. I took the zone names, pins, and value range from the BSP source.

ItemDetails
LocationTop of the body. Three zones: front, middle, back
ChipSi12T (I2C address 0x68)
ConnectionCoreS3 internal I2C (the bus that links parts inside the unit; SCL=G11, SDA=G12). No Grove port used
ValuesStrength 0–3 per zone (0 = not touched)

The BSP touch class (TouchSensor_Class) behaves like an M5Unified button: it is “pressed” while any zone is touched and “released” once all zones read 0.
The default sensitivity is Low / Level 3, which a BSP comment says matches the original StackChan firmware.
If you touch all three zones in order (front→middle→back or back→middle→front) and both the front→middle and middle→back onset gaps are longer than 30 ms and shorter than 400 ms, the BSP reports a swipe (a stroke). I used whether that swipe flag fired to tell a tap from a stroke.

Making the two voice clips

The voice comes from Irodori-TTS, the same model that produces the filler clips my voice chat plays while waiting for a reply. I sent text straight from my PC to the voice server’s TTS and used the returned WAV as-is.

For “Nandesuka?”, I generated three clips with different seeds and used the first.
For “Ehehe~”, seeds 1–3 all came out sounding mischievous. This TTS API only exposes text, seed, a length multiplier, and step count; there is no emotion control. So I changed the spelling and regenerated each variant with two seeds.

TextLength multiplierUsed?
えへへ~ (first three clips)1.0No (mischievous)
えへへ~♪1.0No
えへへ、うれしいな~ (“ehehe, I’m happy~“)1.0No (too long, about 2.7 s)
わぁ、えへへ~ (“wow, ehehe~“)1.0No
えへへぇ~1.2 (slower)No
えへへ~😊1.0Yes, seed 1

Test program for tap and stroke

If it reacted on first contact, a stroke would trigger “Nandesuka?” as soon as the finger hit the first zone, so the program decides tap vs stroke when the finger is lifted.

TouchClassified asReaction
Swipe flag firedStroke”Ehehe~” + head shake + pink LEDs
No swipe, released within 0.8 sTap”Nandesuka?” + blue LEDs
No swipe, held longer than 0.8 sHand restingNothing

The head shake uses only the horizontal yaw servo, a motor you drive to a target angle, via the BSP’s Motion.moveX() with an angle and a speed (0–1000). The first version swung ±20° twice and returned to center at speed 600. Touches that start during the shake or within 0.3 s after it stops are not acted on; they only go to the log.

Both clips (48 kHz / 16-bit / mono; “Nandesuka?” 1.28 s, “Ehehe~😊” 1.36 s) are embedded in the program as C arrays instead of being read from an SD card. Together they are about 250 KB, and the build came to 1,120,375 bytes, 35% of the 3,145,728-byte program area.

For every touch, the program prints each zone’s onset time, the gaps between zones, peak strength, and the result over serial. I also added a mode that prints the strength values every time they change.

EP n=17 dur=550 zones=3 first=[0,121,253] gapFM=121 gapMB=132 max=[3,3,3] swipe=F class=stroke

dur is how long the touch lasted, first is the time (ms) from the first touched zone until front, middle, and back were each touched, gapFM and gapMB are the front→middle and middle→back gaps, and max is peak strength.

Tapping

I tapped the top of the head five times. Volume started at 160 on the 0–255 scale, which was too loud, so I dropped it to step 2 (internal value 51) on the same 0–10 scale I used for the voice chat volume UI.

#DurationZonesResult
177 msFrontTap
277 msFront, middleTap
399 msFront, middleTap
4121 msFront, middleTap
5121 msFront, middleTap

Four of the five taps hit the front and middle zones at the same time.

Listening to it, though, a tap right after the previous “Nandesuka?” finished sometimes got no reply. Quick repeated taps did get replies.

Checking playback alone

Every tap that made it into the log had issued a play command. The second tap came about 0.1 s after the first clip finished.

In case the speaker was dropping a play command issued right after playback ended, I added a command that repeats playback without any touch: play again 100 ms after the previous clip ends, five times.
The log showed all five entering the playing state for 1.24–1.26 s, and I heard all five, so the speaker was fine.

Checking whether the tap was detected

The five taps in the table were all logged. Going back through the log, the other taps that got no reply were not in it as touches at all, let alone as playback.

Toward the face end of the head is the spot where the internal cable connector plugs in during assembly, and past that is the CoreS3 itself. With the strength-printing mode on, I tapped from the connector area back to the rear.

Where I tappedZonesPeak strength
Around the connectorNone0
Just behind the face (screen)Front2
Middle of the headFront, middle3, 3
Rear edgeBack3

The BSP’s “front” is the face side. From the outside I couldn’t tell where the front pad starts, and I had just been tapping wherever.
The missing replies came from tapping outside the pad, not from a hardware fault or a bug.

Stroking

I stroked from the face side toward the back at a normal speed.

DurationFront→middleMiddle→backPeak (front, middle, back)Result
550 ms121 ms132 ms3,3,3Stroke

It registered as a front-to-back swipe, the screen switched to “stroked,” and it said “Ehehe~” while shaking its head.

No response after the head shake

After that stroke and head shake, neither taps nor strokes got any response.
In the log, about 1.5 s after the shake started, all three zones jumped to strength 3 (max) with nobody touching them, and they stayed there for almost two minutes. The program ends a touch when every zone returns to 0, so the touch never ended and nothing after it could be classified.

A capacitive touch sensor uses its untouched reading as a baseline and decides “touched” from the change against it. Calling the BSP’s recalibrate() (which retakes that baseline) over serial brought everything back to 0 immediately. The BSP comment describes it as the function to use when an external disturbance, such as power-rail switching, shifts the baseline. Which part of the head shake causes this is unknown.

At this point the shake took 6.3 s at speed 600 and kept going well after the 1.4 s “Ehehe~” finished, so I changed that at the same time.

ItemBeforeAfter
Head shakeTwo round tripsOne round trip (right → left → center)
Speed600900
After the shakeNothingCall recalibrate() 0.3 s after it stops
Stuck touchNothingCall recalibrate() after 10 s of continuous touch

The shake now takes 2.5–2.8 s: it starts with the voice and stops a little over a second after the voice ends.

Check after switching to one round trip

I alternated taps and strokes back to back. In real use nobody taps and strokes this rapidly, but that is what the video shows.

#ResultDurationFront→middleMiddle→backJust before
1Stroke616 ms132 ms143 msIdle
2Tap88 ms--4.4 s after the shake stopped
3Stroke297 ms77 ms88 ms1.5 s after the tap reply
4Tap77 ms--1.5 s after the shake stopped
5Tap110 ms--Idle
6Stroke208 ms66 ms44 msDuring the tap reply
7Tap88 ms--2.8 s after the shake stopped

Stroke #6, made while the tap reply was still playing, was classified as a stroke too.
On the other hand, taps made in the same video while the head was shaking got no response at all. The program deliberately ignores touches during the shake, so no reaction is expected. But the strength values at that time stayed at 0 even when I tapped.

Touch during the head shake

The LEDs are also pink during the shake, so I checked whether the LEDs were involved. With no head shake and only the LEDs held at the same pink, all 11 taps and strokes were classified correctly and strength reached 3, so the LEDs do not seem to be the cause.

The strength values during the shake were different every time I tried.

RunStrength during the shake
Before fixing the shakeAll three zones stuck at 3 with nobody touching
Check after switching to one round tripStayed at 0 even when tapped
LEDs held onA 78 ms reading, then some zones stuck for 2 s, back to 0 after recalibrate() once the shake stopped

Nobody taps the robot’s head while it is moving, and the tap position shifts while it moves anyway, so I decided not to touch it during the shake. The program keeps ignoring touches during the shake and calls recalibrate() once it stops.

Stroke speed range

Just as I couldn’t tell where to tap, a real stroke might start partway along the head or slow down at the end, so I varied the stroke speed to find where strokes stop registering.

Slow strokes

Five strokes, each slower than the last, starting from a normal speed.

#DurationFront→middleMiddle→backPeak (front, middle, back)Result
1374 ms99 ms99 ms3,3,3Stroke
2814 ms198 ms231 ms3,3,3Stroke
31,419 ms407 ms418 ms3,3,3Hand resting (no reaction)
42,057 ms484 ms638 ms3,3,3Hand resting (no reaction)
52,035 ms528 ms517 ms3,3,3Hand resting (no reaction)

A 231 ms gap registered as a stroke; a 407 ms gap did not. Strokes that covered the head end to end in about 0.8 s still worked.
The three slow ones lasted longer than the 0.8 s tap limit, so they were simply ignored instead of being misread as taps.

During the head shake after stroke #1, all three zones got stuck at 3 again. The recalibrate() after the shake stopped brought them back to 0, and stroke #2 was classified normally.

Fast strokes

Then five strokes, each faster than the last.

#DurationFront→middleMiddle→backPeak (front, middle, back)Result
1253 ms77 ms66 ms3,3,3Stroke
2220 ms77 ms66 ms3,3,1Stroke
31,996 ms0 ms0 ms3,3,3Ignored (during the shake)
4154 ms66 msBack not detected3,2,0Tap (“Nandesuka?“)
5Not logged----

The faster the stroke, the lower the back zone’s strength (1 on #2, 0 on #4), until the last zone stopped registering.
Stroke #4 only hit the front and middle zones and ended in 0.15 s, so it was classified as a tap and got “Nandesuka?” back. The fastest one, #5, left the strength at 0 and was not logged at all.
The BSP reports a response-cycle setting of 2. If the chip only reacts to contact that lasts a certain time, a zone the finger crosses in an instant may simply not register.

Stroke #3 began during the head shake after #2 and stayed at 3 on all zones until the post-shake recalibrate(). The log can’t tell whether I stroked during the shake or the zones just got stuck.

Two more strokes, then separating missed strokes from taps

To avoid overlapping with the recalibration right after the shake stops, I waited a bit after the shake and stroked twice more.

#DurationFront→middleMiddle→backPeak (front, middle, back)Result
1198 ms88 ms33 ms3,3,2Stroke
2297 msFront not detected33 ms0,3,3Tap (“Nandesuka?”)

On #2 the front zone stayed at 0 the whole time, and only the middle and back zones responded. The stroke seems to have started behind the front zone.

I then checked whether missed strokes that ended up as taps could be told apart from real taps by the numbers. I used every touch outside the head shake from the runs where strength was logged every time (from the tap-position test onward). The numbers are the largest spread between responding zones in onset time and in release time.

TypeCountOnset spreadRelease spread
Tap150–44 ms0–44 ms
Missed stroke read as a tap233 ms, 66 ms44 ms, 55 ms
Stroke1278–429 ms55–407 ms

One of the taps, from the run with the LEDs held on, touched front then middle 44 ms apart, which falls between the missed strokes’ 33 ms and 66 ms. With these numbers I couldn’t separate fast strokes that only hit two zones from taps, so I left the classification as the BSP does it.

If misclassification turns out to be a problem in actual use, I’ll adjust it then.