Signals watchapp

Touch 기능과 Speaker 사용법을 익혀보기 위해 만든 모르스부호 연습기다. 영어와 한국어를 지원하고, 한국어는 오토마타가 내장되어 있어서 단순히 한글 자모를 표시하는게 아니라 완성형 글자를 표시한다. 사용법은 페블 앱스토어 앱 소개페이지에 올린 것을 보면 될 것이고, 여기서는 Signals 앱 개발 뒷얘기를 기록에 남겨둔다.2026-09-29

Sequencer

데이터 공급자(producer)와 소비자(consumer) 패턴의 하나인 Transducer 패턴을 C 언어로 포팅한 것은 Pebble 앱에서 빈번한 비동기 처리를 좀 더 직관적으로 처리하고 싶어서였다. Signals 앱의 예를 들자면, 사용자가 화면 터치를 할 때 그에 맞춰 적절한 dot 또는 dash 소리가 나면서, 동시에 모르스 패턴의 PATH가 강조되는 요구사항을 해결하는데 핵심 요소로 쓰였다. Pebble Time 2의 PebbleOS는 이제 더 이상 FreeRTOS가 아니라 자체 커널을 가졌지만 Audio처리는 생각보다 쉽지않고 코드 흐름에 민감하다. 오해를 방지하기 위해 분명히 하고 싶은 것은 “static audio data” 처리에는 전혀 문제가 없다는 점이다. 하필이면 Signals 앱 같이 터치센서에서 들어오는 신호에 맞춰 동적으로 소리를 내줘야 하는 앱에서 발생하는 까다운 점 이란 사실을 미리 밝혀둔다.

그 까다로움이란 PCM audio device가 소리를 내기 위한 절차와 처리방식에서 비롯된 것인데 static audio data는 그 크기와 길이가 정해져 있기 때문에 소리를 낼 때, 또는 끝날 때 크게 문제가 발생하지 않는다. 그러나 화면을 터치할 때 소리가 시작되야하고 화면에서 손을 뗄 때 소리가 멈춰야하는 그 단순한 과정을 처리함에 있어 현재 PebbleOS에서 제공하는 API로는 찌직 거리는 지터링이 발생한다는 점이다. 아주 듣기 싫은 그 소리를 내는 앱은 전혀 실용적이지 않다. 모르스부호 연습한다는 이유만으로도 용납되기 힘든 잡음이다.

초반에는 입력 데이터가 갑작스럽게 주어지거나 끝나기 때문에 문제라고 추측해서 시작 또는 끝 부분에서 10ms 정도 구간동안 데이터를 fade-in/out 시키듯 점진적 증가/감소를 적용했었다. 확실히 ‘딱!’ 끊어지는 소리는 줄었으나 여전히 지터링이 심했는데, 듣다보니 지터링의 발생 패턴에서 코드 흐름에 영향을 받는다는 것이 테스트로 알게됐다. 그 말인즉슨, 화면 Refresh 하는 동안 음이 끊어지는 경우가 많았고 글자를 처리하는 부분에서 간헐적으로 끊어졌는데 끊어질 때마다 처리하는 부분은 캐쉬/버퍼처리 관련 코드였다. 게다가 소리를 멈추는 과정에서 비동기 AppTimer() 호출이 잦았는데 그 타이머 포인터 처리하는 루틴 조차 영향을 준 것이다. 짜증;;; Claude 또는 Astra 에게 맡기면 되지 않느냐 라고 반문할수도 있겠지만, 걔네들이 만드는 코드는 너무 장황하다. 아무리 Heap Memory가 2배로 늘어났어도 그 ㅅㄲ들이 만든 코드를 보고 있으면 짜증이 솟구친다. 게다가 그 놈들에게 코드를 맡기면 페블 코딩의 의미가 없다. 이건 순전히 내 감각을 유지하느냐 못하냐의 문제이기 때문이다. 그래서, 고민하다가 앞서 언급한 Transducer를 구현해서 Producer(터치)와 Consumer(소리처리, 화면처리)로 전체적 흐름을 나누고 Audio 공급을 Screen Refresh Rate에 맞추기로 계획을 변경했다.

아래는 Sequencer 의 헤더정의다:

typedef struct {
   uint32_t ms;
   void (*action)(void *context);
} SequenceStep;

typedef struct {
   AppTimer *timer;
   const SequenceStep *steps;
   void *context;
   uint16_t repeat_limit;
   uint16_t repeat_count;
   uint16_t step_count;
   uint16_t step_index;
   bool running;
   void (*on_error)(void *context); // Asynchronous timer allocation failure only.
} Sequencer;

그러니까 화면 업데이트를 호출하기 직전에 Audio 처리를 위한 Sequence 데이터를 채우고 비동기로 흐름을 분기해야 한다. 화면은 이미지가 아니라 상당히 많은 GPath 요소로 그려지기 때문에 무시못할 시간을 소모하기 때문인데, 30fps를 지원하지만 화면 redraw를 최대한 줄이는게 관건이다. Sequencer는 Linked List와 달리 고정된 크기의 데이터 블록이라서 런타임 노드계산이 없어도 그 흐름을 이어갈 수 있다. 즉, 터치 신호가 언제 시작됐고 언제 올라가는지를 모르는 상태에서, SequenceStep이 시작된 후로 소리를 계속 내게 하다가 다음번 SequenceStep에서 필요한 계산을 비동기로 나누어서 처리한다는게 핵심이다. Audio 버퍼가 비지않게 하기 위해 최대 10초 분량의 데이터를 밀어넣는다. 비게되면 여지없이 “딱” 소리가 난다. 그러다가 SequenceStep이 멈추라고 하면 Fade-Out 흐름을 적용시켜 10ms 동안 소리를 스무드하게 감쇄시킨다. Touch -> Audio 및 Screen Refresh를 동기 흐름으로 타고 가게 하는건 너무 쉬운 일이지만 도중에 화면 Refresh가 끼어버리면 여지없이 Audio에서 지터링이 발생하여 듣기 싫은 잡음으로 번지는 것이다. Screen Refresh를 하지 않으면 현재 입력중인 모르스부호의 PATHWAY가 어떤지 표시가 되지 않는다. 이 앱의 특징은 터치와 소리 및 화면업데이트에 있으므로, 어쩔 수 없이 이 문제를 해결해야 했다. 다행히도 Sequencer 덕분에 최대 20~30ms, 터치에 비해 소리 및 화면 업데이트가 약간 뒤처지더라도 급작스런 Audio 시작과 끝 맺음으로 인해 ‘딱딱’거리는 소리도 제거될 수 있었고 지터링 없는 모르스부호 오디오 소리를 낼 수 있었다.


XState 같은 상태머신 체인, Signal/Slot

Signals 앱은 한국어 모르스부호 연습기로 만들기 시작했으나 도중에 영문 모르스부호를 추가했고 특수 문자 및 숫자도 넣을 계획이다. 각각의 상태에 따라 한국어는 오토마타가 적용되야하지만 영문과 숫자/특수문자는 필요가 없다. 이 조건은 각각의 상태머신이 그 입력상태와 출력 상태를 유지한다면, 입력도중 한국어/영어/특수문자 사이를 자유롭게 전이할 수 있을거라 생각했다. 기존 상태머신은 Global 스코프 용이었기 때문에, static function 내에서 동작하는 local 상태머신이 필요했다. 그래서, 간단히 매크로를 이용해서 OOP 스타일로 확장했다:

// [Synaplex 2.0 Multi-FSM Instance OOP-style Convenience Macros]
#define fsm_emit(table, evt)            transit((table), (evt))
#define fsm_current(table)              ((table) ? (table)->current : NULL)
#define fsm_is_state(table, st)         ((table) && (table)->current == (st))
#define fsm_set_context(table, ctx)     do { if (table) (table)->context = (ctx); } while(0)
#define fsm_set_name(table, n)          do { if (table) (table)->name = (char *)(n); } while(0)

말만 OOP 스타일이지 객체가 항상 첫 인수로 주어지는 건 정말 꼴볼견이다.

어쨌든, 각각의 화면마다 상태를 처리하므로, Sequencer로 구현된 REPLAY 기능은 단순히 해당 테이블에 event를 날려서 상태전이를 유도해서 소리/위치/글자를 말 그대로 REPLAY되게 하였다.

사용자가 입력한 모르스부호는 문자열로 치환되서 저장되고, single-select-click을 입력하면 저장된 문자열의 글자를 하나하나 SequenceStep으로 대응시켜 비동기로 정확한 길이의 dot/dash/pause 오디오를 출력한다. 물론 화면에서 해당 모르스코드의 PATHWAY를 표시하는 건 아주 쉬운 일이다.

nBack, Slow, Glimmer를 거치면서 무엇을 event로 해야하고 무엇을 signal로 해야하는지에 대한 규칙이 완성되어 있어서 Signals에서는 고민없이 섞어 쓸 수 있었다. 요약하자면, 상태머신의 상태를 변경시키는 것은 event, 상태 머신과 관련없는 것은 signal 이다. 물론 signal에 의해서 상태가 변경될 수도 있으나 구분 자체 분명하다.

아마도 콩알만한 기기에서 별 짓을 다한다고 생각할 수도 있을 것이다. 반대로 말하자면, 이렇게 하지 않기 때문에 만들어 놓은 페블앱의 코드는 보면 다시 처음부터 시작하고 싶어지는 것일수도 있다. 구조적으로 근본적인 모듈을 만들고 그 모듈간에 상호 운영을 통해 복잡한 앱을 만들어내되 인지부하를 줄이는 것, 이게 내가 페블코딩을 하는 즐거움의 이유다.


PKJS, Alloy

2026년, PebbleSDK / Emualator / pebble-qemu-wasm / PebbleOS … 관련 프로젝트의 상태를 보고있으면 뭐랄까… 솓구치는 흥미로움에 현실의 괴로움은 잠시 떨어져 있어달라고 간청하고 싶은 마음이 막 솟구친다. 이렇게까지 모든게 오픈된 임베디드 환경을 보고 있으면 뭐라도 할 수 있을 것 같은 착각에 사로잡힐 수 밖에 없다. Mercury-Neptune Opposition (Orb: -3.56°) 이라서 그런가…

그중에서도 단연 흥미로운 패키지는 PKJS와 Alloy 이다. 이름에 ‘-JS’ 가 들어가서 JavaScript 관련 어쩌고라고 생각할 수 있겠지만, 반은 맞고 반은 다르다. PKJS는 모바일용 PebbleCore 앱에 내재되어 있으면서 Pebble Deivce내에서 동작하는 페블앱과 통신하면서 외부 세계를 이어주는 Companion App 모듈이다. Alloy는 C언어와 JavaScript ES5 언어가 Hybrid로 섞인 새로운 페블앱의 포맷이다. 그러니까 C언어가 ES5 JS와 API를 호출할 수 있다는 소리고, 컴파일된 바이트코드가 PebbleOS에서 해석되서 실행되는 포맷이란 말이다! 햐;;; 정말,,, C+JS ==>바이트코드 라니… 느무 느무 흥미롭지 않은가??

그러니까;; Custom PebbleOS + QEMU + PKJS + Alloy 조합은 어느 임베디드 기기에서나 동작할 수 있는 환경으로 발전할 가능성이 있지않을까?! 지난번 uConsole에서 QEMU의 동작을 조금 수정하면서 깨달은 것인데, QEMU 자체가 docker 처럼 container로써 동작하고, 거기에 Firmware 조차도 앱처럼 필요시 갈아치울 수 있으면서 PKJS + Alloy 조합은 QEMU 바깥과 내부를 이어주는 기능을 제공하고 있어서 Embedded 환경에서 이런 고급스런? 황송한? 기능은 정말 몸둘바를 모르게 한다. pebble-qemu-wasm은 페이지 한쪽 귀퉁이 또는 보이지 않는 영역에서 PKJS+Alloy을 동작시킬 수 있고, 그 페이지에 있는 페블앱이 로컬 또는 서버의 QEMU 안의 앱과 통신하는 그런 환경이란 말이다. 이런 환경은 값비싼 하드웨어가 필요없다. 원하는대로 갖가지 모습의 Gadget 으로 변화무쌍하게, 때로는 장난감 처럼, 때로는 인테리어 부품처럼, 보이지만 드러나지 않는 가구처럼, 그렇게 보이고 위치하면서 LLM과 소통하며 코딩한 내용을 이행할 수 있다.

재미난 것들이 넘쳐나는, 괴로운 현실을 탈출하고 싶게 만드는 흥미로운 것들이지않나??


PS: “Signals 앱은 한국어 언어팩이 필요한데, 만들어둔 C83k를 공개하지 않는 이유”? –> 아무도, 관심없는데 굳이?? 그리고 누구든 토큰만 있으면 스스로 언어팩을 조립할 수 있잖은가?