Skip to content

File wake_engine.h

File List > espos_voice > include > espos_voice > wake_engine.h

Go to the documentation of this file

/* SPDX-FileCopyrightText: 2026 Dirk Wahrheit */
/* SPDX-License-Identifier: Apache-2.0 */
#pragma once

// WakeEngine — ON-DEVICE wake word via esp-sr AFE + WakeNet.
//
// Runs the wake word detector on the panel itself from the raw mic (no
// network audio), which is far more reliable for far-field / small-MEMS-mic
// audio than streaming to a remote openWakeWord (that path scored the panel's
// mic near zero). esp-sr's AFE gives noise suppression + AGC tuned for exactly
// this embedded case.
//
// The model (e.g. wn9_jarvis_tts) is flashed to the "model" partition and
// loaded at runtime via esp_srmodel_init("model"). Which word is compiled in
// is chosen in sdkconfig (CONFIG_SR_WN_WN9_JARVIS_TTS etc.).
//
// SINGLE MIC CONSUMER: the engine owns the mic while listening. Its feed task
// is the only record_pcm() caller. On a detection it fires a callback; the
// owner (WyomingSatellite) PAUSES the engine (pause()) for the pipeline so
// run_mic() becomes the sole reader, then RESUMES it. pause()/resume() stop
// and restart the feed task cleanly — no concurrent capture.

#include <atomic>
#include <functional>

#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "freertos/task.h"

#include "espos_audio/audio_driver.h"

namespace espos_voice {

class WakeEngine {
 public:
  // on_detect is called (from the fetch task) when the wake word fires.
  // muted_fn (optional) returns true to suppress detection (privacy).
  using DetectFn = std::function<void()>;
  using MutedFn = std::function<bool()>;

  explicit WakeEngine(espos_audio::AudioDriver* audio) : audio_(audio) {}
  ~WakeEngine();

  void set_on_detect(DetectFn fn) { on_detect_ = std::move(fn); }
  void set_muted_fn(MutedFn fn) { muted_fn_ = std::move(fn); }
  // Pre-AFE software gain on the mic feed (1 = off). The onboard MEMS mic is
  // quiet; a small boost can help, but too much just amplifies the noise
  // floor and hurts detection. Set before start().
  void set_input_gain(int g) { input_gain_ = g; }
  // WakeNet detection threshold (0.4-0.9999; 0 = model default). Lower = more
  // sensitive. Set before start().
  void set_threshold(float t) { threshold_ = t; }

  // Initialise AFE + WakeNet from the "model" partition and start the feed +
  // fetch tasks. Returns false if no model is present or AFE init fails
  // (wake is then simply unavailable; the rest of the satellite is fine).
  bool start();
  void stop();

  // Pause / resume mic consumption. pause() blocks until the feed task has
  // released the mic (so the caller can hand it to run_mic()); resume()
  // restarts capture. Idempotent.
  void pause();
  void resume();

  bool running() const { return running_.load(); }
  bool listening() const { return listening_.load(); }  // capturing right now
  uint32_t detections() const { return detections_.load(); }
  const char* word() const { return word_; }

  // Diagnostic: copy the most recent ~2 s of the EXACT PCM fed to WakeNet
  // (post-gain) into `out` (up to max_samples), newest last. Returns the count
  // copied. Lets /mic_probe dump what the detector actually sees on-device.
  size_t pcm_snapshot(int16_t* out, size_t max_samples);
  // Milliseconds since the probe ring was last written, or UINT32_MAX if it
  // never has been. The ring keeps serving its last contents after the feed
  // stops, so an age is the only way to tell live audio from a frozen buffer.
  uint32_t pcm_age_ms() const;
  // Drop any retained probe PCM (privacy: called when the mic is muted). The
  // feed loop stops filling the ring while muted and clears it on this call.
  void clear_probe();

 private:
  static void feed_task_tramp(void* arg);
  static void fetch_task_tramp(void* arg);
  void feed_loop();
  void fetch_loop();
  // Start/stop the mic on the mono or 2ch handle per dual_mic_.
  void capture_start();
  void capture_stop();

  espos_audio::AudioDriver* audio_;
  DetectFn on_detect_;
  MutedFn muted_fn_;

  // esp-sr handles (void* to keep esp-sr headers out of this header).
  void* afe_handle_ = nullptr;  // const esp_afe_sr_iface_t*
  void* afe_data_ = nullptr;    // esp_afe_sr_data_t*
  int feed_chunk_ = 0;          // samples per feed (per channel)
  int feed_channels_ = 1;
  // True when AFE was created with format "MM" and the feed pulls the board's
  // 2-channel [MIC1,MIC2] path (start_capture2/record_pcm2). Decided once at
  // start() from audio_->supports_dual_mic(); the mono path is the fallback.
  bool dual_mic_ = false;
  int input_gain_ = 1;
  float threshold_ = 0.0f;
  char word_[24] = "";

  TaskHandle_t feed_task_ = nullptr;
  TaskHandle_t fetch_task_ = nullptr;
  // Given once by each loop as it returns, so stop() JOINs the tasks before
  // freeing the AFE / releasing the mic (never vTaskDelete mid-inference).
  SemaphoreHandle_t feed_exited_ = nullptr;
  SemaphoreHandle_t fetch_exited_ = nullptr;
  // pause() ⇄ feed_loop() handshake: the feed loop gives this exactly once
  // when it parks *for a pause* (not for mute), and pause() takes it. A
  // generation counter makes each pause wait for a fresh park rather than a
  // stale token.
  SemaphoreHandle_t feed_paused_ = nullptr;

  std::atomic<bool> running_{false};
  std::atomic<bool> paused_{false};
  std::atomic<bool> listening_{false};
  std::atomic<uint32_t> detections_{0};
  // Holds the feed loop out of the AFE while resume() re-arms WakeNet.
  std::atomic<bool> rearming_{false};
  // Published by feed_loop(): true while it is parked (not in record_pcm() or
  // feed()). Lets resume() wait for real quiescence instead of sleeping.
  std::atomic<bool> feed_parked_{false};

  // Diagnostic probe ring: the last ~2 s of post-gain PCM fed to WakeNet.
  static constexpr size_t kProbeSamples = 32000;  // 2 s @ 16 kHz
  int16_t* probe_ = nullptr;
  size_t probe_head_ = 0;
  size_t probe_filled_ = 0;
  // Tick of the last probe write, with a separate written-flag: tick 0 is a
  // legitimate timestamp, so it cannot double as "never written".
  std::atomic<uint32_t> probe_last_write_{0};
  std::atomic<bool> probe_written_{false};
  SemaphoreHandle_t probe_mutex_ = nullptr;
  // Count of probe operations (pcm_snapshot / clear_probe) currently touching
  // probe_mutex_/probe_ from OTHER tasks (the /hello httpd path, the mic-mute
  // widget). stop() waits for this to reach zero before freeing them, so a
  // probe call racing engine teardown can't use freed memory.
  std::atomic<int> probe_busy_{0};
};

}  // namespace espos_voice