diff --git a/CLAUDE.md b/CLAUDE.md
index 44044ac..df94b99 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -17,6 +17,7 @@ other work. Remove an item once he says it's done.
| App (menu name) | Script | Spec | Based on |
| --- | --- | --- | --- |
| Speed Gauge | `src/Apps/AG-SpdGa.lua` | `specs/001-speed-gauge/` | DFM Speed Announcer (Dave McQueeney, MIT) |
+| Flameout Alarm | `src/Apps/AG-FlmOt.lua` | `specs/003-flameout-alarm/` | original |
Keep this table current when an app is added.
diff --git a/CREDITS.md b/CREDITS.md
index 2d10f08..882d561 100644
--- a/CREDITS.md
+++ b/CREDITS.md
@@ -80,6 +80,29 @@ Amy model was trained on is undocumented. They were generated with
[`tools/voice/`](tools/voice/README.md), which can regenerate them (the
user chose to commit them, 2026-10-03; spec FR-035).
+## Flameout Alarm sounds — Piper and the "Amy" voice
+
+Flameout Alarm (`AG-FlmOt`) is original work; no code comes from another
+app. Its alarm sounds use the same **Piper** engine and **"Amy"**
+(`en_US-amy-medium`) voice model by **Mycroft / Rhasspy** as Speed Gauge's
+voice, credited above:
+
+- the urgent "Flameout!" callout, sped up, pitch-shifted, equalized and
+ compressed by [`tools/voice/make_flameout.py`](tools/voice/README.md);
+- "Engine telemetry lost", "Flameout alarm armed" and "Engine relit".
+
+The lock tone is synthesized by the same script and contains no voice.
+
+The generated files are in
+[`src/Apps/AG-FlmOt/`](src/Apps/AG-FlmOt/) and are licensed **CC BY-SA 4.0**
+(Amy model), not MIT; that folder's `CREDITS.txt` carries the attribution.
+No Piper code is part of this repository or of the app.
+
+The ECU telemetry facts behind the app (spec 003, research R1) come partly
+from Thomas Ekdahl's Jeti ECU telemetry app
+([thomasekdahlN/jeti](https://github.com/thomasekdahlN/jeti)), which was
+studied for facts only; none of its code is used.
+
## JETI model official demos
[`docs/examples/jeti-demos/`](docs/examples/jeti-demos/) holds unmodified demos
diff --git a/README.md b/README.md
index e8a7504..7360419 100644
--- a/README.md
+++ b/README.md
@@ -8,12 +8,74 @@ spec-driven workflow (GitHub Spec Kit + Claude Code) in VS Code.
| App | Script | Status | Spec | Based on |
| --- | --- | --- | --- | --- |
| Speed Gauge | [`AG-SpdGa.lua`](src/Apps/AG-SpdGa.lua) | Released (0.3.0) | [001](specs/001-speed-gauge/spec.md) | DFM Speed Announcer by Dave McQueeney (MIT) |
+| Flameout Alarm | [`AG-FlmOt.lua`](src/Apps/AG-FlmOt.lua) | Released (0.1.0) | [003](specs/003-flameout-alarm/spec.md) | Original |
Speed Gauge speaks in its own voice (Piper "Amy"), in
`src/Apps/AG-SpdGa/voice/`. Those files are licensed CC BY-SA 4.0, not MIT
(see [CREDITS.md](CREDITS.md)); [`tools/voice/`](tools/voice/README.md)
regenerates them. Without them the app works in the transmitter's voice.
+### Flameout Alarm
+
+Watches a turbine's RPM telemetry and sounds an urgent, repeating alarm
+("Flameout! Flameout! Flameout!" and a lock tone every 5 seconds, with stick
+vibration) when the engine spools down after it has been running. A
+double-size window shows RPM as a segmented bar with an idle marker.
+
+**It is advisory only.** It never controls the model or the engine. It does
+not replace the ECU's own failsafe, shutdown or auto-restart logic, or the
+pilot's own monitoring.
+
+**It only works if the turbine's RPM reaches the transmitter as a normal
+telemetry sensor**, i.e. you can pick it in the transmitter's sensor list.
+What is known per ECU brand ([research](specs/003-flameout-alarm/research.md#r1-how-ecus-deliver-rpm-to-a-jeti-transmitter)):
+
+| ECU | RPM as a telemetry sensor |
+| --- | --- |
+| JetCat | Yes, through JetCat's, VSpeak's, Digitech's or CB-Electroniks' Jeti converter |
+| Xicoy (V6/V10, also JetsMunt) | Yes, through the Xicoy telemetry adapter (sensor group "Turbine") |
+| KingTech | Yes, through the KingTech telemetry unit or a VSpeak/Digitech converter |
+| Swiwin | Yes, through VSpeak's Swiwin converter. Direct connection: unconfirmed |
+| JetCentral | Unconfirmed: the Telemetry Adapter V2 uses its own Lua app |
+| Enjet Power | Unconfirmed |
+
+RPM shown only on a JetiBox screen, or only inside another maker's Lua app,
+can't be used. Swiwin users: set the receiver's output period to 11-13 ms,
+not "Auto" (a known Swiwin ECU issue, unrelated to this app).
+
+**Setup** (Applications → Flameout Alarm). Monitoring stays off until three
+things are set:
+
+- **RPM sensor.** If the ECU sends RPM in units other than RPM (e.g.
+ thousands), set **Sensor scale** so **Live RPM** reads true engine RPM.
+- **Idle RPM**, typed from the ECU's setup, in thousands (35.0 = 35,000
+ RPM). Compare it with **Live RPM** at idle. Too high, and the app arms only
+ on a run-up and a relight won't clear the alarm until you open the
+ throttle. Too low, and the alarm comes later and a restart clears it
+ sooner.
+- **Cut switch**, assigned in its Cut (engine off) position. Moving it to Cut
+ disarms the app silently and is the only way to silence an alarm.
+
+How it decides:
+
+- A turbine start overshoots idle and slowly settles back down, so the app
+ arms on an **arming threshold** below idle (default 90%) held for the
+ arming time (3 s). The arming threshold must stay above any RPM the engine
+ reaches before it runs on its own; raise the percentage if your engine's
+ start or auto-restart comes close to it.
+- RPM below the **flameout threshold** (default 70% of idle) for the
+ detection delay (1 s) starts the alarm. A throttle chop to idle stays
+ above it.
+- During an ECU auto-restart the alarm keeps going; it clears once RPM holds
+ the arming threshold for the arming time.
+- If telemetry stops for the telemetry-loss delay (2 s), the app says "Engine
+ telemetry lost" instead of alarming.
+
+A **Test alarm** switch plays the real alarm on the ground (not while armed).
+The sounds in `src/Apps/AG-FlmOt/` are generated with
+[`tools/voice/make_flameout.py`](tools/voice/README.md) and licensed CC BY-SA
+4.0, not MIT. Install = `AG-FlmOt.lua` plus the `AG-FlmOt/` folder.
+
## Install with JETI Studio
1. In JETI Studio open **File → Configuration**, and add this line to the
@@ -27,7 +89,9 @@ regenerates them. Without them the app works in the transmitter's voice.
Studio's Lua app list; select one and install it.
Each app installs exactly its released version (its files come from the
-release tag). Descriptions: [Speed Gauge](docs/apps/speed-gauge.md).
+release tag). Descriptions: [Speed Gauge](docs/apps/speed-gauge.md),
+[Flameout Alarm](docs/apps/flameout-alarm.md) (in JETI Studio once its
+`Apps.json` entry is published).
For maintainers: after tagging a release, run
`python tools/publish/make_apps_json.py`, commit `Apps.json` and merge it to
diff --git a/docs/apps/flameout-alarm.html b/docs/apps/flameout-alarm.html
new file mode 100644
index 0000000..94be4f7
--- /dev/null
+++ b/docs/apps/flameout-alarm.html
@@ -0,0 +1,61 @@
+
+
+
+
+ Flameout Alarm
+
+
+ Flameout Alarm
+ An urgent, repeating alarm when a turbine flames out, from the
+ engine's RPM telemetry, and an RPM bar for the main screen, for the
+ DS-24 II / DC-24 II.
+
+
+ Flameout alarm: "Flameout! Flameout! Flameout!" and a
+ rapid lock tone every 5 seconds, with stick vibration. It cuts in
+ over other apps' speech.
+ No false alarms on the ground: arms only once the
+ engine has run at idle, ignores the start sequence and throttle
+ chops, and disarms silently on the throttle cut switch.
+ Auto-restart aware: the alarm keeps going during an ECU
+ restart and clears once the engine holds idle again.
+ Telemetry loss is not a flameout: you hear "Engine
+ telemetry lost" instead.
+ RPM window: a segmented RPM bar with an idle marker and
+ the RPM number; a red FLAMEOUT banner on a flameout.
+ Test alarm switch to hear the real alarm on the
+ ground.
+
+
+ Advisory only. It never controls the model or the engine,
+ and does not replace the ECU's failsafe, shutdown or auto-restart
+ logic, or your own monitoring.
+ Needs a turbine whose RPM reaches the transmitter as a normal
+ telemetry sensor: JetCat, Xicoy and KingTech through their Jeti
+ telemetry adapters or a VSpeak/Digitech converter, Swiwin through
+ VSpeak's converter. JetCentral, Enjet and Swiwin's direct connection
+ are unconfirmed.
+ Setting up
+
+ Add Flameout Alarm in Applications - User Applications .
+ In its settings choose the RPM sensor (check Live
+ RPM ; set Sensor scale if it's off by 10x or 1000x),
+ enter Idle RPM in thousands from your ECU's setup (35.0 =
+ 35,000 RPM), assign the Cut switch in its Cut position, then
+ tick Monitoring .
+ Put Flameout Alarm at double size on a page in
+ Timers/Sensors - Displayed telemetry .
+ Optional: assign a Test alarm switch to hear the
+ alarm.
+
+ Defaults: arms at 90% of idle held for 3 s, alarms below 70% of
+ idle for 1 s, "telemetry lost" after 2 s.
+ Version: 0.1.0
+ Compatibility: DC/DS-24II , firmware 6.x
+ Voice: Piper text-to-speech with the "Amy" voice by Mycroft /
+ Rhasspy; the sound files are licensed CC BY-SA 4.0. Flameout Alarm: MIT
+ license, Copyright (c) 2026 Aaron George.
+ Source and full credits:
+ github.com/Flazzarious/jeti-lua-apps
+
+
diff --git a/docs/apps/flameout-alarm.md b/docs/apps/flameout-alarm.md
new file mode 100644
index 0000000..1f7908b
--- /dev/null
+++ b/docs/apps/flameout-alarm.md
@@ -0,0 +1,73 @@
+# Flameout Alarm
+
+An urgent, repeating alarm when a turbine flames out, from the engine's RPM
+telemetry, and an RPM bar for the main screen, for the JETI
+**DS-24 II / DC-24 II**.
+
+
+
+## What it does
+
+- **Flameout alarm:** "Flameout! Flameout! Flameout!" followed by a rapid
+ lock tone, repeating every 5 seconds, with three short pulses on both
+ sticks. It cuts in over other apps' speech.
+- **No false alarms on the ground:** the app arms only once the engine has
+ been running at idle (it ignores the start sequence and the start
+ overshoot), stays quiet through throttle chops, and disarms silently when
+ you move the throttle cut switch to Cut.
+- **Auto-restart aware:** while the ECU tries a restart, the alarm keeps
+ going; it clears once the engine holds idle again.
+- **Telemetry loss is not a flameout:** if the RPM data stops, you hear
+ "Engine telemetry lost" instead of the alarm.
+- **RPM window:** a segmented RPM bar with a yellow idle marker and the RPM
+ as a number (double size), or a compact bar (single size). On a flameout
+ the window turns into a red FLAMEOUT banner.
+
+ 
+- **Test alarm switch:** hear the real alarm on the ground, without an
+ engine.
+
+**Advisory only.** The app never controls the model or the engine. It does
+not replace the ECU's own failsafe, shutdown or auto-restart logic, or your
+own monitoring.
+
+## Requirements
+
+- JETI **DS-24 II or DC-24 II**, firmware 6.0 or newer. On other DC/DS-24
+ transmitters the alarm works and the window shows text only.
+- A turbine whose **RPM reaches the transmitter as a normal telemetry
+ sensor**: JetCat, Xicoy and KingTech through their Jeti telemetry adapters
+ or a VSpeak/Digitech converter, Swiwin through VSpeak's converter.
+ JetCentral, Enjet and Swiwin's direct connection are unconfirmed. RPM shown
+ only on a JetiBox screen or inside another maker's app can't be used.
+
+## Setting up
+
+1. Add **Flameout Alarm** in *Applications → User Applications*.
+2. Open its settings (*Applications → Flameout Alarm*). Monitoring stays off
+ until these three are set:
+ - **RPM sensor**: your ECU's RPM. **Live RPM** shows what the app reads;
+ if it's off by 10× or 1000×, set **Sensor scale**.
+ - **Idle RPM**, in thousands (35.0 = 35,000 RPM), from your ECU's setup.
+ Check it against Live RPM with the engine idling.
+ - **Cut switch**, assigned while holding the switch in its Cut (engine
+ off) position.
+ Then tick **Monitoring**.
+3. Put **Flameout Alarm** at double size on a page in *Timers/Sensors →
+ Displayed telemetry*.
+4. Optional: assign a **Test alarm** switch and hold it on to hear the alarm.
+
+The defaults suit most engines: arming at 90% of idle held for 3 s, alarm
+below 70% of idle for 1 s, "telemetry lost" after 2 s. If your engine's start
+or auto-restart climbs close to 90% of idle before it's running on its own,
+raise the arming percentage. Settings are saved per model.
+
+## Credits and licenses
+
+- Voice: **Piper** text-to-speech with the voice model **"Amy"** by
+ Mycroft / Rhasspy. The sound files (in `/Apps/AG-FlmOt/`) are licensed
+ **CC BY-SA 4.0**.
+- Flameout Alarm itself: MIT license, Copyright (c) 2026 Aaron George.
+
+Source, full credits and license texts:
+[github.com/Flazzarious/jeti-lua-apps](https://github.com/Flazzarious/jeti-lua-apps)
diff --git a/docs/apps/img/flameout-alarm-armed.png b/docs/apps/img/flameout-alarm-armed.png
new file mode 100644
index 0000000..36b4bd5
Binary files /dev/null and b/docs/apps/img/flameout-alarm-armed.png differ
diff --git a/docs/apps/img/flameout-alarm-disarmed.png b/docs/apps/img/flameout-alarm-disarmed.png
new file mode 100644
index 0000000..2acd468
Binary files /dev/null and b/docs/apps/img/flameout-alarm-disarmed.png differ
diff --git a/docs/apps/img/flameout-alarm-flameout.png b/docs/apps/img/flameout-alarm-flameout.png
new file mode 100644
index 0000000..e6c8b2e
Binary files /dev/null and b/docs/apps/img/flameout-alarm-flameout.png differ
diff --git a/docs/apps/img/flameout-alarm-icon.png b/docs/apps/img/flameout-alarm-icon.png
new file mode 100644
index 0000000..2f8b7a6
Binary files /dev/null and b/docs/apps/img/flameout-alarm-icon.png differ
diff --git a/docs/apps/img/flameout-alarm-idle.png b/docs/apps/img/flameout-alarm-idle.png
new file mode 100644
index 0000000..689aa5f
Binary files /dev/null and b/docs/apps/img/flameout-alarm-idle.png differ
diff --git a/docs/drafts/flameout-alarm.md b/docs/drafts/flameout-alarm.md
new file mode 100644
index 0000000..09971c6
--- /dev/null
+++ b/docs/drafts/flameout-alarm.md
@@ -0,0 +1,315 @@
+# Draft spec input: Flameout Alarm
+
+**Status:** Pre-spec draft (2026-10-02). Not yet run through Spec Kit.
+Alarm sound approved by Aaron (2026-10-02).
+**Next step:** run `/speckit-specify` with the *Feature description* below,
+then bring in the *Success criteria* and *Edge cases* sections, and feed the
+*Open questions / research* items to `/speckit-clarify`.
+
+Proposed script name: `AG-FlmOt.lua` (menu name "Flameout Alarm").
+Confirm the name before release; constitution V makes filenames permanent.
+
+---
+
+## Feature description (paste into `/speckit-specify`)
+
+Flameout Alarm is an in-flight telemetry app for the Jeti DS-24 II that warns
+the pilot, by sound and on screen, when the turbine appears to have flamed out.
+
+**Problem.** A turbine flameout in flight is easy to miss. The model keeps
+flying, the engine noise is already distant, and the pilot may not notice the
+loss of thrust until the model is slow, low or out of glide range. Many ECUs
+send RPM over telemetry, so the transmitter can tell the moment the engine
+spools down while the pilot still expects it to be running.
+
+**Goal.** Give an immediate, unmistakable alarm when the RPM falls below a
+critical threshold while the engine is supposed to be running, and stay
+silent during normal start-up, idle, throttle chops and intentional shutdown.
+
+**Why an app and not a native alarm.** Jeti's native telemetry alarms can fire
+on "RPM below X", optionally gated by a switch. But they can't tell "the
+engine has not started yet" from "the engine was running and stopped", so a
+low-RPM alarm also sounds during the whole start sequence and after every
+shutdown. The app adds arming: it only alarms after the engine has
+demonstrably been running at idle or above.
+
+**Per-model enable and RPM sensor assignment.** Not every turbine sends an
+RPM telemetry sensor. The app is therefore configured per model:
+
+- Monitoring is **off by default** on every model.
+- The user turns it on for a model only if that model's turbine provides an
+ RPM sensor, and assigns that sensor from the model's telemetry sensors
+ (`system.getSensors()`).
+- Monitoring cannot be turned on until an RPM sensor is assigned. If the list
+ has no sensors, the app explains that the turbine/ECU in this model must
+ send RPM telemetry and that telemetry must have been received at least once
+ (transmitter and receiver powered on).
+- When monitoring is off or no sensor is assigned, the app is fully inactive:
+ no sounds, no warnings, and its telemetry window shows "OFF".
+- The enable flag and sensor assignment are saved per model
+ (`system.pSave` / `system.pLoad`), so each model keeps its own setting.
+
+**Startup and auto-restart RPM.** During a start, and during an ECU
+auto-restart after a flameout, the starter and ignition spin the engine
+through a range of RPM below idle. That startup range must never arm the
+app and must never clear an active flameout alarm. Only reaching idle RPM
+(or above) counts as "engine running".
+
+**Alarm sound (approved).** The alarm repeats a 5-second cycle until it is
+acknowledged, cleared by a relight, or stopped by the Cut switch:
+
+1. **Voice callout:** "Flameout! Flameout! Flameout!" spoken quickly and
+ imperatively (about 2 s), in the same Piper "Amy" voice that Speed Gauge
+ uses for all its speech (Speed Gauge spec, User Story 6 and
+ FR-030–FR-037), so all AG- apps sound consistent.
+2. **Lock tone:** a rapid, high-pitched beeping like a missile-lock warning
+ fills the rest of the cycle.
+
+A new callout starts every 5 seconds (callout start to callout start), so
+the pilot hears the voice again, then the beeping resumes.
+
+Both sounds are audio files in the app's own `AG-FlmOt/` folder, not
+references to Speed Gauge's files, so the install stays standalone. The
+voice file follows Speed Gauge's voice rules (FR-034–FR-036):
+
+- Generated by the repo's voice script (`tools/voice/`) from the same Piper
+ Amy model. The script's phrase list gains "Flameout!" with output to
+ `AG-FlmOt/`.
+- Delivered urgently, unlike Speed Gauge's normal-paced callouts: Piper
+ length scale 0.70 (about 30% faster), noise scale 0.5, pitch raised about
+ 1 semitone without changing speed (ffmpeg `asetrate` ×1.07 +
+ `atempo` 0.9346), high-pass at 150 Hz, +4 dB presence boost at 3 kHz and
+ light compression (threshold −18 dB, ratio 4, attack 3 ms, release
+ 60 ms, makeup 4 dB) so it cuts through wind and engine noise. Repeats are
+ separated by 0.12 s.
+- Mono, 16-bit, 16 or 22.05 kHz; leading/trailing silence trimmed;
+ normalized to the same peak loudness as Speed Gauge's files.
+- Not committed to git (`.gitignore`); each developer generates it locally.
+ A credits note for Piper (MIT) and the Amy model (CC BY-SA 4.0) goes in
+ the folder and in `CREDITS.md`.
+
+The lock tone is a plain synthesized tone (no voice licensing), so it can be
+committed or generated by the same script.
+
+Approved samples (2026-10-02, kept in the claude.ai "LUA Apps" project chat,
+not committed): lock tone `AG-FlmOt_lock_loop.wav` (seamless 2 s loop,
+1800 Hz with a 3600 Hz overtone, 45 ms on / 35 ms off with 3 ms fades,
+12.5 beeps/s, 22.05 kHz mono 16-bit); urgent voice
+`AG-FlmOt_flameout_x3.wav`; and the full 20 s demo
+`AG-FlmOt_alarm_demo.wav` (4 cycles). The voice samples were made with
+Piper Amy **low** (16 kHz); the release file should use the same Amy model
+quality as Speed Gauge, with the settings above.
+
+**Setup inputs.**
+
+- Enable monitoring for this model (off / on). On only if an RPM sensor is
+ assigned.
+- RPM telemetry sensor for this model.
+- Throttle safety switch and its Cut (OFF) position. Same 2-position switch
+ used by the Turbine Throttle Setup app, but selected independently here.
+- Idle RPM (the arming threshold). Entered by hand or captured with a
+ "learn idle" helper (record the steady idle RPM with the engine running).
+ It must sit at or above idle and above any RPM the engine reaches during
+ start-up or auto-restart.
+- Flameout threshold in RPM, below idle. Suggested as a set percentage of the
+ idle RPM (editable), low enough that a throttle chop to idle doesn't cross
+ it.
+- Arming time: how long RPM must stay at or above idle before the app arms,
+ or before an alarm clears after a relight.
+- Detection delay: how long RPM must stay below the flameout threshold before
+ alarming, to ride out telemetry glitches.
+- Optional: ECU status sensor, if the ECU reports one.
+
+**Behavior.**
+
+0. **Off.** When monitoring is off for the model or no RPM sensor is
+ assigned, the app does nothing at flight time except show "OFF" in its
+ telemetry window.
+1. **Disarmed** at model load when monitoring is on. No alarm in this state.
+ Startup-range RPM (below idle) keeps it Disarmed.
+2. **Arming.** When the switch is not in Cut and RPM stays at or above the
+ idle RPM for the arming time, the app moves to **Armed** and plays a short
+ "armed" confirmation (optional).
+3. **Flameout.** While Armed, if RPM stays below the flameout threshold for
+ the detection delay and the switch is not in Cut, the app raises the
+ alarm: the 5-second cycle of "Flameout!" ×3 then lock tone, repeating,
+ plus stick vibration if available and a large on-screen "FLAMEOUT" in its
+ telemetry window.
+4. **Auto-restart.** While in Flameout, RPM rising through the startup range
+ (starter cranking, ignition, fuel ramp) does not clear the alarm, and
+ wobbles in that range never re-trigger it as a new event. The alarm
+ clears only when RPM is back at or above idle for the arming time; the
+ app then returns to Armed and can announce "relit" (optional). If the
+ restart fails, the flameout state persists.
+5. **Intentional shutdown.** Moving the switch to Cut disarms immediately and
+ silently, so a normal shutdown never alarms.
+6. **Acknowledge.** The pilot can silence the alarm with a key press. The
+ app stays in an acknowledged Flameout state (still shown on screen) until
+ RPM is at or above idle for the arming time, or the switch goes to Cut.
+ It does not re-alarm for the same event.
+7. **Telemetry lost.** If the RPM sensor stops reporting valid data while
+ Armed, the app gives a distinct "telemetry lost" warning, not the flameout
+ alarm, so the pilot can tell the two apart.
+8. **Telemetry window.** Shows current RPM, the state (OFF / DISARMED /
+ ARMED / FLAMEOUT / NO TELEMETRY) and the active thresholds.
+
+**Constraints.**
+
+- Advisory only. The app never drives any output: it never calls
+ `system.registerControl`, `system.setControl` or `system.setProperty`
+ (constitution I). It only reads telemetry and switches and plays sound.
+- It does not replace the ECU's own failsafe, shutdown or auto-restart logic,
+ nor the pilot's own monitoring. The README must say so, and must say the
+ app only works on turbines that send RPM telemetry.
+- Low CPU: the loop does minimal work per call and never blocks; when Off it
+ returns immediately.
+- Standalone install: only `AG-FlmOt.lua` and its own `AG-FlmOt/` folder
+ (alarm audio). No dependency on other apps in this repo. Any shared module
+ from `src/Apps/lib/` must be listed in the plan, the install instructions
+ and any release package.
+- All settings persist per model with `system.pSave` / `system.pLoad`.
+
+---
+
+## Success criteria
+
+- **SC-001.** In replayed or emulated flameout profiles, the alarm starts
+ within (detection delay + 0.5 s) of RPM crossing below the threshold in
+ 100% of runs.
+- **SC-002.** Zero alarms across a full normal flight profile: power-on,
+ start sequence, idle, run-ups, throttle chops to idle, landing, and
+ shutdown via the Cut switch.
+- **SC-003.** Zero flameout alarms before the engine first reaches idle
+ (start sequence, failed starts, cool-down).
+- **SC-004.** Telemetry loss produces the "telemetry lost" warning, never the
+ flameout alarm, in 100% of tests.
+- **SC-005.** A pilot can enable the app for a model and configure sensor,
+ switch and thresholds in under 3 minutes without any other manual.
+- **SC-006.** The app never changes any transmitter output or setting
+ (servo outputs and model file unchanged after a session with no user edits).
+- **SC-007.** Copying only the app's files (plus any listed lib modules) to
+ `/Apps` on a transmitter with no other AG- apps is enough to load and run.
+- **SC-008.** Loop execution stays within Jeti's CPU budget with no "CPU
+ limit" errors over a 30-minute emulator session.
+- **SC-009.** On a model where monitoring is off (the default) or no RPM
+ sensor is assigned, the app produces zero sounds or warnings in any
+ scenario, including telemetry loss.
+- **SC-010.** Enable state and sensor assignment are kept per model: enabling
+ it on model A does not enable it on model B, and both settings survive a
+ transmitter restart.
+- **SC-011.** In replayed auto-restart profiles, the flameout alarm never
+ clears while RPM is in the startup range, clears within (arming time +
+ 0.5 s) of RPM holding at or above idle, and fires no second alarm for the
+ same flameout, in 100% of runs. A failed restart leaves the alarm active.
+- **SC-012.** While the alarm is active, a "Flameout!" ×3 callout starts
+ every 5 s (±0.3 s), the lock tone plays between callouts with no audible
+ gap or overlap, and the cycle continues until acknowledge, relight or Cut,
+ in 100% of tests.
+
+## Edge cases
+
+- **Turbine has no RPM sensor.** User leaves monitoring off; the app stays
+ silent and shows OFF. Adding the app to such a model must never cause
+ alarms.
+- **User tries to enable with no sensor assigned.** Blocked, with an
+ explanation.
+- **No telemetry sensors listed at setup** (receiver off, or ECU not sending).
+ Setup explains that telemetry must be received once before a sensor can be
+ assigned.
+- **Throttle chop from full to idle.** RPM falls fast but stays above the
+ flameout threshold. No alarm. (Drives how low the threshold sits relative
+ to idle.)
+- **Brief telemetry dropout or a single bad sample.** Detection delay rides it
+ out; no alarm.
+- **Sensor reports 0 or invalid at power-on.** Stays Disarmed.
+- **Failed start or hot start abort.** RPM never holds at idle for the arming
+ time, so no alarm.
+- **ECU auto-restart after flameout.** Starter cranking and ignition push RPM
+ up through the startup range. The alarm keeps going (or stays
+ acknowledged) until RPM holds at or above idle for the arming time, then
+ clears and the app re-arms.
+- **Auto-restart fails partway.** RPM rises into the startup range and falls
+ back. No clear, no second alarm; the original flameout stays active.
+- **Startup RPM peaks close to idle.** If the arming threshold is set below
+ the highest startup/restart RPM, a restart could clear the alarm early.
+ Setup warns when the idle value looks too low (e.g. below a learned idle)
+ and the README explains the rule.
+- **Idle RPM drifts slightly below the learned value** (temperature,
+ altitude, fuel). The engine may not arm at idle and only arms on the first
+ run-up. Acceptable for v1; a small tolerance setting is an open question.
+- **Relight, Cut or acknowledge mid-callout or mid-beep.** All alarm sound
+ stops at once; no further callouts.
+- **Another app (e.g. Speed Gauge) is mid-announcement when the alarm
+ fires, or when a callout is due.** The alarm must interrupt or pre-empt it,
+ not queue behind it.
+- **Audio file missing from `AG-FlmOt/`** (e.g. voice file not generated).
+ The app falls back to built-in beeps (`system.playBeep`) plus the on-screen
+ FLAMEOUT, and settings shows "voice files missing". A missing file must
+ never silence the alarm.
+- **Pilot flips to Cut during the alarm.** Alarm stops; state Disarmed.
+- **Wrong sensor selected** (e.g. EGT instead of RPM). Setup warns if the
+ selected sensor's unit isn't RPM-like, or if the value never changes.
+- **Assigned sensor disappears** after an ECU swap, receiver rebind or model
+ copy. App shows "sensor not found" and stays Disarmed instead of erroring;
+ the user can re-assign or turn monitoring off.
+- **Model copied from another model** (settings carried over). The copied
+ sensor assignment may not exist on the new model's receiver; same handling
+ as above.
+- **Model loaded with no telemetry ever received.** App shows NO TELEMETRY,
+ never alarms.
+- **Thresholds set nonsensically** (flameout ≥ idle). Setup blocks it and
+ explains.
+- **Two engines.** Out of scope for v1; note as a possible extension (one
+ instance per RPM sensor).
+
+---
+
+## Open questions / research (feed to `/speckit-clarify` and the plan)
+
+1. **ECU RPM telemetry.** Which of Aaron's turbines/ECUs send RPM over Jeti EX
+ (via Jeti Expander / ECU converter), and for those: units (rpm vs. x1000),
+ decimals, update rate, and what the value does on flameout (falls smoothly,
+ drops to 0, or goes invalid).
+2. **Auto-restart profile.** Which of Aaron's ECUs auto-restart after a
+ flameout, and what RPM they reach during cranking, ignition and fuel ramp
+ before settling at idle. Use logs to confirm the startup range always
+ stays clearly below idle, and to build the SC-011 replay tests.
+3. **ECU status sensor.** Do Aaron's ECUs send a status / state sensor (e.g.
+ "Running", "Flameout", "Restart", "Failsafe")? If so, could a state change
+ confirm or trigger the alarm faster than RPM, or confirm a relight? Status
+ codes differ per brand.
+4. **Default thresholds.** Typical idle and max RPM for Aaron's engines, and
+ what percentage of idle is safely below a throttle-chop dip. Gather RPM
+ logs from real flights (Jeti log files) to set defaults and to build the
+ replay tests for SC-001/SC-002. Decide whether the idle/arming threshold
+ needs a small tolerance below the learned idle.
+5. **Rate-of-drop detection.** Would RPM decay rate catch a flameout earlier
+ than a fixed threshold without false alarms on chops? Evaluate on logs;
+ keep threshold-only for v1 unless clearly better.
+6. **Speed Gauge voice (resolved: Piper Amy).** Confirm which Amy model
+ quality (low or medium) `tools/voice/` uses, and add per-phrase overrides
+ (speed, pitch, EQ, compression) to the script so the urgent Flameout
+ delivery doesn't change Speed Gauge's normal callouts.
+7. **Audio playback API.** Confirm on firmware 6.x: `system.playFile`
+ (priority / immediate mode so the alarm interrupts other announcements,
+ including Speed Gauge), how to time the 5 s callout cycle from `loop()`
+ with the lock tone in between (WAV loop stopped at callout time vs.
+ `system.playBeep` bursts), how to stop playback instantly, and the
+ supported WAV format. Also confirm `system.vibration` on the DS-24 II.
+8. **Sensor validity.** Confirm the `valid` field returned by
+ `system.getSensorByID` and how quickly it goes false after telemetry loss.
+9. **Sensor identity across sessions.** Confirm that the sensor `id` + `param`
+ pair from `system.getSensors()` is stable across power cycles so a saved
+ assignment keeps working, and how it behaves after a receiver rebind or
+ ECU/converter swap.
+10. **Interaction with native alarms and voice output.** Avoid clashing with
+ any existing native RPM alarm or the transmitter's own telemetry-loss
+ announcement.
+11. **Emulator testing.** Can JETI Studio replay a log or let us drive a fake
+ RPM sensor to script the flameout, chop and auto-restart profiles? If
+ not, plan a small test harness in `types/` or a debug mode that feeds
+ synthetic RPM.
+12. **Stub check.** Confirm `types/jeti.lua` declares `getSensors`,
+ `getSensorByID`, `playBeep`, `playFile`, `vibration` and
+ `registerTelemetry`; add any missing ones and note them in
+ `docs/jeti-api-notes.md`.
diff --git a/docs/jeti-api-notes.md b/docs/jeti-api-notes.md
index 7ee0cfb..a389d95 100644
--- a/docs/jeti-api-notes.md
+++ b/docs/jeti-api-notes.md
@@ -137,6 +137,10 @@ the emulator's numbers above:
- To see live per-call numbers, call `system.getCPU()` at the end of `loop()`
and of the print function and log them (Speed Gauge was measured this way:
init 24, loop 0–1, small/double draw 12–18, full-screen draw 27–43).
+- Flameout Alarm's double-size window (12 opaque renderer polygons, 12
+ thin opaque outlines, three texts) measured **5–6%** per call on the
+ DS-24 II (2026-10-06, `DEBUG_CPU`): 5% armed, 6% with the FLAMEOUT
+ banner. Opaque fills and outlines are cheap, as below.
- Cost follows semi-transparent anti-aliased drawing (renderer polylines with
alpha, e.g. glow bands) far more than plain fills: removing a full-window
`drawFilledRectangle` and the filled face polygon changed nothing, while
@@ -159,6 +163,17 @@ the emulator's numbers above:
- It also replaces `system.playFile`, `playNumber`, `playBeep`,
`playSystemSound` and `vibration` with `print`: in the emulator, audio and
vibration appear as lines in the Lua console instead of sound.
+ `system.stopPlayback` is **not** replaced, so a stop leaves no console
+ line; it shows only as the repeating lines ending (Flameout Alarm,
+ 2026-10-06).
+- **`io.open` in the emulator needs a relative path** (JETI Studio 6.04,
+ 2026-10-05, Flameout Alarm): `io.open("/Apps/AG-FlmOt/cycle.wav", "r")`
+ returns nil, while `io.open("Apps/AG-FlmOt/cycle.wav", "r")` works ("r"
+ and "rb" alike). The transmitter opens the absolute path (Speed Gauge's
+ voice check passes there). A file-existence check should try the
+ absolute path, then the same path without its leading "/". Playback keeps
+ the absolute path. Speed Gauge 0.3.0's check doesn't do this yet, so in
+ the emulator it always falls back to DFM's sounds and `playNumber`.
- **The emulator plays no Lua audio even without that app** (JETI Studio,
firmware 6.04, observed 2026-10-03: `playFile` with the telemetry app
removed was silent). Anything that must be heard, such as WAV formats and
diff --git a/specs/003-flameout-alarm/checklists/requirements.md b/specs/003-flameout-alarm/checklists/requirements.md
new file mode 100644
index 0000000..59b7585
--- /dev/null
+++ b/specs/003-flameout-alarm/checklists/requirements.md
@@ -0,0 +1,52 @@
+# Specification Quality Checklist: Flameout Alarm
+
+**Purpose**: Validate specification completeness and quality before proceeding to planning
+**Created**: 2026-10-04
+**Feature**: [spec.md](../spec.md)
+
+## Content Quality
+
+- [x] No implementation details (languages, frameworks, APIs)
+- [x] Focused on user value and business needs
+- [x] Written for non-technical stakeholders
+- [x] All mandatory sections completed
+
+## Requirement Completeness
+
+- [x] No [NEEDS CLARIFICATION] markers remain
+- [x] Requirements are testable and unambiguous
+- [x] Success criteria are measurable
+- [x] Success criteria are technology-agnostic (no implementation details)
+- [x] All acceptance scenarios are defined
+- [x] Edge cases are identified
+- [x] Scope is clearly bounded
+- [x] Dependencies and assumptions identified
+
+## Feature Readiness
+
+- [x] All functional requirements have clear acceptance criteria
+- [x] User scenarios cover primary flows
+- [x] Feature meets measurable outcomes defined in Success Criteria
+- [x] No implementation details leak into specification
+
+## Notes
+
+- Both clarification markers resolved 2026-10-04: a Cut switch is required
+ to enable monitoring (FR-003, FR-005); Cut is the only way to silence the
+ alarm, with no separate acknowledge (FR-019). Recorded under
+ Clarifications in the spec.
+- Revised 2026-10-04 for start overshoot: idle RPM is typed in only; a
+ separate arming threshold (90% of idle) replaces "at or above idle"; "learn
+ idle" removed. Re-validated: all items still pass.
+- Revised 2026-10-04 for the RPM bar display: User Story 7 (now P2),
+ FR-031–FR-031c, SC-013. Screen sizes and colors in FR-031/FR-031a are
+ deliberate product constraints (target hardware, consistency with Speed
+ Gauge). Re-validated: all items still pass.
+- Revised 2026-10-04: double-size layout is now a segmented sweep bar after
+ the user's dash reference; straight bar is the single-size layout and the
+ double-size fallback. Re-validated: all items still pass.
+- The approved audio parameters (FR-026, FR-027) and the asset folder name
+ (FR-028) are deliberate: the sound was approved as a product decision on
+ 2026-10-02. API names stay out of the spec; the draft's open questions go
+ to `/speckit-clarify` and `/speckit-plan`.
+- Items marked incomplete require spec updates before `/speckit-clarify` or `/speckit-plan`
diff --git a/specs/003-flameout-alarm/contracts/audio-events.md b/specs/003-flameout-alarm/contracts/audio-events.md
new file mode 100644
index 0000000..7b9030e
--- /dev/null
+++ b/specs/003-flameout-alarm/contracts/audio-events.md
@@ -0,0 +1,51 @@
+# Contract: Audio and vibration
+
+All sounds are in `src/Apps/AG-FlmOt/`, generated by
+`tools/voice/make_flameout.py` and committed under CC BY-SA 4.0 with the
+folder's `CREDITS.txt` (spec FR-026–FR-028, research R5). `A` =
+`/Apps/AG-FlmOt/`. `I` = `AUDIO_IMMEDIATE`, `Q` = `AUDIO_QUEUE`.
+
+| File | Content | Length |
+| --- | --- | --- |
+| `cycle.wav` | urgent "Flameout!" ×3, then lock tone | 5.000 s exactly |
+| `cycletl.wav` | urgent "Flameout!" ×3, "Engine telemetry lost", lock tone | 5.000 s exactly |
+| `tlost.wav` | "Engine telemetry lost" | ≈ 1.3 s |
+| `armed.wav` | "Flameout alarm armed" | ≈ 1.3 s |
+| `relit.wav` | "Engine relit" | ≈ 1 s |
+
+Mono, 16-bit, 22.05 kHz, peak −1 dBFS.
+
+## Events
+
+| Event | Trigger (data-model.md) | Files present | Cycle files missing (R6) |
+| --- | --- | --- | --- |
+| Alarm start | ARMED → FLAMEOUT | `playFile(A.."cycle.wav", I)`; `vibration(false, 4)`, `vibration(true, 4)` | `playBeep(9, 1800, 120)` now and 2.5 s later; vibration |
+| Alarm cycle | FLAMEOUT, 5 s after the previous cycle start | as alarm start, or `cycletl.wav` once if `tlPending` | as above |
+| Alarm stop: relight | FLAMEOUT → ARMED | `stopPlayback(AUDIO_IMMEDIATE)`; then `playFile(A.."relit.wav", Q)` if `sayRel` | `stopPlayback(AUDIO_IMMEDIATE)` |
+| Alarm stop: Cut | any → DISARMED by Cut while FLAMEOUT | `stopPlayback(AUDIO_IMMEDIATE)` | same |
+| Alarm stop: monitoring off | settings change sets `active` false while FLAMEOUT | `stopPlayback(AUDIO_IMMEDIATE)` | same |
+| Telemetry lost | ARMED, invalid for `lossMs` | `playFile(A.."tlost.wav", I)` | skipped (screen shows NO TELEMETRY) |
+| Telemetry lost | FLAMEOUT, invalid for `lossMs` | next cycle plays `cycletl.wav` (R4) | beeps continue |
+| Armed | DISARMED → ARMED, `sayArm = 1` | `playFile(A.."armed.wav", Q)` | skipped |
+| Test start | test switch off → on, state OFF or DISARMED (R13) | as alarm start (`cycle.wav`) | as alarm start |
+| Test cycle | test on, 5 s after the previous cycle start | as alarm cycle (`cycle.wav` only) | as alarm cycle |
+| Test stop | test switch off, or the app arms | `stopPlayback(AUDIO_IMMEDIATE)` | same |
+
+Rules:
+
+- **Nothing sounds while OFF or DISARMED** (FR-012, SC-009) except a test
+ alarm the pilot starts with the test switch (FR-030a).
+- Cycle start times are `cycleAt + 5000·k`, checked on the 100 ms tick
+ (SC-012: ±0.3 s). If the app fell more than one cycle behind (it never
+ should), it restarts the rhythm from `now`.
+- **Pre-emption** (FR-025): `AUDIO_IMMEDIATE` stops any foreground playback,
+ including other apps' speech. Another app's `AUDIO_IMMEDIATE` (e.g. Speed
+ Gauge's stall warning) or a transmitter alarm can cut a cycle short; the
+ alarm resumes at the next 5-second boundary (R3).
+- File existence is checked once in `init()` (five `io.open` calls), never
+ in `loop()`.
+- The confirmations use `AUDIO_QUEUE` so they never cut another app's
+ callout; the alarm and "telemetry lost" use `AUDIO_IMMEDIATE`.
+- **Unverified until transmitter testing:** the gap between `playBeep`
+ repeats; that `stopPlayback(AUDIO_IMMEDIATE)` silences mid-file with no
+ tail; the firmware's own telemetry "valid" timeout (R9).
diff --git a/specs/003-flameout-alarm/contracts/settings-form.md b/specs/003-flameout-alarm/contracts/settings-form.md
new file mode 100644
index 0000000..07da71a
--- /dev/null
+++ b/specs/003-flameout-alarm/contracts/settings-form.md
@@ -0,0 +1,51 @@
+# Contract: Settings form
+
+One form, registered under the Applications menu as "Flameout Alarm"
+(`system.registerForm(1, MENU_APPS, ...)` with a close callback). Keys and
+ranges: [data-model.md](../data-model.md). Decisions: research R9, R10.
+
+Rows, top to bottom (`hint` = one `FONT_MINI` line, shown only when stated):
+
+| # | Row | Component | Behavior |
+| --- | --- | --- | --- |
+| 1 | **Flameout Alarm** | heading | |
+| 2 | Monitoring | checkbox `en` | Refused unless sensor, Cut switch and idle RPM are set: box unticks and row 3 names what's missing (FR-003) |
+| 3 | hint | label | "Needs: RPM sensor, Cut switch, idle RPM" (only the missing items); hidden when complete |
+| 4 | **Engine** | heading | |
+| 5 | RPM sensor | selectbox | "(none)" + every sensor except date/time and GPS, as "Device / Label"; a saved sensor that isn't present shows " (not found)" (FR-022). Rebuilt each time the form opens |
+| 6 | hint | label | No sensors at all: "No telemetry yet. The ECU must send RPM and the receiver must be on. Some adapters take up to a minute." (FR-004) |
+| 7 | hint | label | Unit isn't `rpm`, `U/min` or `1/min`: "Unit is '', not RPM. Check the sensor, or set Sensor scale." (FR-010, R9) |
+| 8 | Sensor scale | selectbox `scl` | "x1", "x10", "x100", "x1000" (FR-006a) |
+| 9 | Live RPM | label | Scaled live value, e.g. "34,800", or "---"; updated from `loop()` while the form is open, every 500 ms (FR-010) |
+| 10 | Idle RPM (x1000) | intbox `idle`, 1 decimal | 0.0–150.0; "0.0" means not set. Hint below: "From the ECU setup. Check against Live RPM at idle." |
+| 11 | Max RPM (x1000) | intbox `maxR`, 1 decimal | 0.0–300.0; 0.0 = auto (4 × idle). Refused if > 0 and ≤ idle (snaps back, hint) |
+| 12 | **Thresholds** | heading | |
+| 13 | Arming at % of idle | intbox `armP` | 50–98; label shows "= 31.5k" |
+| 14 | Flameout below % of idle | intbox `flP` | 20–95; label shows "= 24.5k". Refused if ≥ arming % (FR-007) |
+| 15 | hint | label | Shown after a refused value: "Flameout must be below arming, and arming below idle." |
+| 16 | Arming time (s) | intbox `armT`, 1 decimal | 1.0–10.0, step 0.5 |
+| 17 | Detection delay (s) | intbox `detT`, 1 decimal | 0.3–5.0, step 0.1 |
+| 18 | Telemetry loss after (s) | intbox `lossT`, 1 decimal | 0.5–10.0, step 0.5 |
+| 19 | **Throttle cut** | heading | |
+| 20 | Cut switch | inputbox `swCut`, non-proportional | Hint below: "Assign with the switch in the Cut (engine off) position." (FR-005, R10) |
+| 21 | **Announcements** | heading | |
+| 22 | Say "armed" | checkbox `sayArm` | FR-009 |
+| 23 | Say "relit" | checkbox `sayRel` | FR-009 |
+| 23a | Test alarm | inputbox `swTest`, non-proportional | Optional. Hint below: "Plays the alarm while on. Not while armed." (FR-030a, R13) |
+| 24 | hint | label | "Voice files missing: alarm uses beeps." when `audioOk` is false (FR-029) |
+| 25 | footer | label | "Flameout Alarm . Advisory only; does not replace the ECU's failsafe." and "Voice: Piper, Amy (CC BY-SA 4.0)." |
+
+Behavior:
+
+- Every change saves at once with `system.pSave` and calls the recompute
+ function (derived values in data-model.md), which may set `active` false
+ and drop the app to OFF (alarm stopped).
+- Turning monitoring on with something missing: the checkbox is set back to
+ unticked with `form.setValue`, row 3 becomes visible. Removing the sensor,
+ Cut switch or idle while enabled also turns `en` off and shows row 3.
+- Refused values (rows 11, 14) are set back to the last good value with
+ `form.setValue`, and row 15 (or the hint under row 11) shows.
+- `formOpen` is set in the form's init and cleared in its close callback;
+ `loop()` touches form components only while it's true.
+- Labels avoid characters outside the supported charset (constitution V);
+ "x" stands in for "×".
diff --git a/specs/003-flameout-alarm/contracts/telemetry-window.md b/specs/003-flameout-alarm/contracts/telemetry-window.md
new file mode 100644
index 0000000..a323d40
--- /dev/null
+++ b/specs/003-flameout-alarm/contracts/telemetry-window.md
@@ -0,0 +1,75 @@
+# Contract: Telemetry window
+
+One window: `system.registerTelemetry(1, "Flameout Alarm", 0, printRpm)`.
+Size 0 lets the pilot place it at single or double size. Full screen is out
+of scope (FR-031c). Design: [design/rpm-window-mockup.svg](../design/rpm-window-mockup.svg),
+regenerated by `design/mockup.py`. Decisions: research R11.
+
+## Layout selection
+
+```
+if w == 157 then h = h - 26 end -- emulator draws its title inside (Speed Gauge)
+if not isDs24II -> text layout
+elseif h >= 45 -> double layout (transmitter 150 x 68)
+else -> single layout (transmitter 150 x 23)
+```
+
+Coordinates below are for the transmitter's 150 × 68 / 150 × 23 windows.
+In the emulator (157 wide) the layout is offset 3 px right.
+
+## Colors
+
+Speed Gauge's palette (its research R8). Red is used for nothing but the
+flameout alarm (FR-031a).
+
+| Element | RGB |
+| --- | --- |
+| Background | 8, 10, 14 (Speed Gauge `C_BG`) |
+| Lit segments, ARMED label | 0, 190, 255 (Cyan) |
+| Unlit segment fill / outline | 34, 38, 44 / 70, 78, 90 |
+| Idle marker | 255, 225, 0 (Yellow) |
+| RPM number | 255, 255, 255 |
+| Labels ("RPM", DISARMED, OFF) | 170, 170, 170 |
+| FLAMEOUT banner | 255, 45, 30 with white text |
+
+## Double layout (150 × 68)
+
+| Element | Position and font | Content |
+| --- | --- | --- |
+| Sweep | 12 segments from x 4 to 146, 2 px gaps; bottom edge `y = 22 + 40·(1 − t)^2.2`, height `6 + 12·t`, `t` = 0 at left, 1 at right | Lit: `ceil(12 · rpm / fullRpm)`, max 12; none without data |
+| Idle marker | 2 px wide, at `idleX`, from 3 px below to 3 px above the sweep there | Always shown when idle is set |
+| State label | x 4, y 2, `FONT_MINI` | OFF, NO SENSOR, NO TELEMETRY, DISARMED, ARMED; TEST (white) while a test alarm plays |
+| RPM number | right-aligned at x 146, top y 32, `FONT_BIG` | "112,400", "---" without data |
+| Unit label | right-aligned at x 146, top y 54, `FONT_MINI` | "RPM"; "NO TELEMETRY" during FLAMEOUT with `lost` |
+| FLAMEOUT banner | filled rect x 4, y 2, 142 × 24; text centered, `FONT_BIG` | Replaces the state label while the alarm sounds (FR-031) |
+
+## Single layout (150 × 23)
+
+| Element | Position and font | Content |
+| --- | --- | --- |
+| Bar | x 4, y 4, 84 × 8; dim fill, cyan fill to `84 · rpm / fullRpm` | |
+| Idle marker | 2 px wide at the idle position, y 1 to 15 | |
+| State label | x 4, y 12, `FONT_MINI` | as above |
+| RPM number | right-aligned at x 146, top y 2, `FONT_NORMAL` | "112,400" / "---" |
+| FLAMEOUT | whole window red, "FLAMEOUT" `FONT_NORMAL` white on the left, number on the right | |
+
+## Text layout (not a DS-24 II)
+
+Theme foreground color: state label on the first line, RPM number on the
+second (`FONT_NORMAL`). FLAMEOUT drawn the same way; the alarm itself
+works on any transmitter.
+
+## Rules
+
+- Nothing is allocated per frame: segment points are computed in `init()`
+ per layout (integer arrays); `rpmText` is rebuilt only when RPM / 100
+ changes (data-model.md).
+- One `lcd.renderer()` created on first draw and reused (verified on the
+ transmitter with Speed Gauge).
+- `lcd` calls only here (constitution VI).
+- Fonts measured on the transmitter: `FONT_MINI` 12, `FONT_NORMAL` 17,
+ `FONT_BIG` 20 px high.
+- **Iteration points** (named constants): segment count, curve exponent,
+ height range, number position. If the sweep reads poorly or the CPU figure
+ is high, first drop the unlit outlines, then use the straight-bar layout at
+ double size with the number above the bar (FR-031 fallback).
diff --git a/specs/003-flameout-alarm/data-model.md b/specs/003-flameout-alarm/data-model.md
new file mode 100644
index 0000000..ef3f454
--- /dev/null
+++ b/specs/003-flameout-alarm/data-model.md
@@ -0,0 +1,131 @@
+# Data Model: Flameout Alarm
+
+Three kinds of data: **settings** (persisted per model), **derived values**
+(recomputed when a setting changes) and **session state** (in memory, reset
+by `init()` on every model load, constitution IV). Decisions are cited as
+R1–R12 ([research.md](research.md)).
+
+## Settings (persisted with `system.pSave`)
+
+Integers, strings and one SwitchItem; booleans as 0/1; times in tenths of
+a second; RPM in hundreds. 17 keys (limit 30).
+
+| Key | Type | Range | Default | Meaning | Spec |
+| --- | --- | --- | --- | --- | --- |
+| `en` | 0/1 | | 0 | Monitoring enabled | FR-001, FR-003 |
+| `sId` | int | | 0 | RPM sensor id (0 = none) | FR-002 |
+| `sPar` | int | | 0 | RPM sensor param | FR-002 |
+| `sLbl` | string | < 64 bytes | "" | Sensor label, for "(not found)" | FR-022 |
+| `scl` | int | 1–4 | 1 | Sensor scale: ×1, ×10, ×100, ×1000 | FR-006a |
+| `swCut` | SwitchItem | | nil | Cut switch, assigned in the Cut position | FR-005, R10 |
+| `idle` | int | 0–1500 | 0 | Idle RPM / 100 (0 = not set) | FR-006 |
+| `maxR` | int | 0–3000 | 0 | Max RPM / 100 (0 = auto: 4 × idle) | FR-031b |
+| `armP` | int | 50–98 | 90 | Arming threshold, % of idle | FR-007 |
+| `flP` | int | 20–95 | 70 | Flameout threshold, % of idle | FR-007 |
+| `armT` | int | 10–100 | 30 | Arming time, 0.1 s | FR-008 |
+| `detT` | int | 3–50 | 10 | Detection delay, 0.1 s | FR-008 |
+| `lossT` | int | 5–100 | 20 | Telemetry-loss delay, 0.1 s | FR-008 |
+| `sayArm` | 0/1 | | 0 | Speak "Flameout alarm armed" | FR-009 |
+| `sayRel` | 0/1 | | 0 | Speak "Engine relit" | FR-009 |
+| `swTest` | SwitchItem | | nil | Test alarm switch or button (optional) | FR-030a, R13 |
+| `cfgV` | int | | 1 | Settings layout version, for future migrations | |
+
+(`swCut` and `swTest` are stored as SwitchItems; `pSave(key, nil)` deletes
+one.)
+
+**Validation** (settings form, [contracts/settings-form.md](contracts/settings-form.md)):
+
+- `en = 1` only if `sId ≠ 0`, `swCut ≠ nil` and `idle > 0` (FR-003).
+- `flP < armP` (FR-007). Arming threshold < idle holds because `armP ≤ 98`.
+- `maxR = 0` or `maxR > idle` (FR-031b edge case).
+- **On load**, every integer is clamped to its range; if a loaded
+ combination is invalid (e.g. `flP ≥ armP` from a hand-edited model), the
+ defaults for `armP`/`flP` are restored and `en` is forced to 0 if a
+ required item is missing.
+
+## Derived values (recomputed on any setting change, never in `loop()`)
+
+| Name | Formula | Use |
+| --- | --- | --- |
+| `active` | `en = 1` and `sId ≠ 0` and `swCut ≠ nil` and `idle > 0` | `loop()` returns right after the test-alarm check when false (FR-012, FR-034) |
+| `kScale` | 1, 10, 100, 1000 by `scl` | RPM = value × `kScale` |
+| `idleRpm` | `idle × 100` | marker, labels |
+| `armRpm` | `idleRpm × armP / 100` | arming and relight (FR-014, FR-018) |
+| `flRpm` | `idleRpm × flP / 100` | detection (FR-015) |
+| `fullRpm` | `maxR × 100`, or `4 × idleRpm` if `maxR = 0` | bar full scale (FR-031b) |
+| `armMs`, `detMs`, `lossMs` | tenths × 100 | timers |
+| `idleX` | idle marker x per layout: `X0 + (idleRpm / fullRpm) · (X1 − X0)` | window (R11) |
+| threshold strings | e.g. "31.5k" | settings rows |
+
+## Session state (reset in `init()`)
+
+| Name | Type | Meaning |
+| --- | --- | --- |
+| `state` | int | `OFF`, `DISARMED`, `ARMED`, `FLAMEOUT` |
+| `rpm` | int or nil | last valid scaled RPM; nil while invalid |
+| `sensorFound` | bool | last read returned an entry (false → NO SENSOR) |
+| `invalidSince` | ms or nil | first invalid sample of the current loss |
+| `lost` | bool | invalid for at least `lossMs` (NO TELEMETRY) |
+| `armSince` | ms or nil | RPM ≥ `armRpm` since (DISARMED, or FLAMEOUT for relight) |
+| `lowSince` | ms or nil | RPM < `flRpm` since (ARMED) |
+| `cycleAt` | ms | start of the current alarm cycle |
+| `beepAt` | ms or nil | second fallback beep burst due (R6) |
+| `tlPending` | bool | play `cycletl.wav` at the next cycle (R4) |
+| `testOn` | bool | a test alarm is playing (R13) |
+| `testPrev` | bool | test switch was on last tick (rising-edge detection) |
+| `audioOk` | bool | both cycle files exist (checked once in `init()`, R6) |
+| `fileOk` | table | per-file existence for the three short phrases |
+| `rpmText`, `rpmHundreds` | string, int | cached "112,400" and the value it was built from |
+| `lastTick` | ms | 100 ms logic tick (R8) |
+| `formOpen` | bool | settings form open (live RPM row) |
+
+## State transitions (each 100 ms tick, R8)
+
+Evaluated in this order. "Valid" = sensor entry exists and `valid` is true.
+
+| # | From | Condition | To | Actions |
+| --- | --- | --- | --- | --- |
+| 0 | any | `active` is false | OFF | stop alarm if sounding; window shows OFF |
+| 1 | any | Cut switch in Cut | DISARMED | `stopPlayback(AUDIO_IMMEDIATE)` if FLAMEOUT; clear all timers, `tlPending`, `lost` (FR-016) |
+| 2 | any | sample invalid | (same) | set `invalidSince` if nil; clear `armSince`, `lowSince`; once `now − invalidSince ≥ lossMs` and not `lost`: `lost = true`; if ARMED play `tlost.wav` (I); if FLAMEOUT set `tlPending` (FR-020, FR-020a). Skip rows 3–5 |
+| — | any | sample valid | (same) | `invalidSince = nil`, `lost = false`, `rpm = value × kScale` |
+| 3 | DISARMED | `rpm ≥ armRpm` held `armMs` | ARMED | play `armed.wav` (Q) if `sayArm` (FR-014) |
+| 4 | ARMED | `rpm < flRpm` held `detMs` | FLAMEOUT | start alarm: `cycleAt = now`, play cycle file (I), vibrate (FR-015) |
+| 5 | FLAMEOUT | `rpm ≥ armRpm` held `armMs` | ARMED | `stopPlayback(AUDIO_IMMEDIATE)`; play `relit.wav` (Q) if `sayRel` (FR-018) |
+| 6 | FLAMEOUT | `now − cycleAt ≥ 5000` | FLAMEOUT | next cycle: `cycletl.wav` if `tlPending` (then clear it) else `cycle.wav`, (I); vibrate; `cycleAt += 5000` (or `now` if more than one cycle behind) |
+
+**Test alarm** (R13), evaluated every tick before row 0 and independent of
+`state`. `T` = test switch on (`getInputsVal(swTest) > 0.5`, false if
+unassigned):
+
+| # | Condition | Actions |
+| --- | --- | --- |
+| T1 | `T` and not `testPrev`, state OFF or DISARMED, not `testOn` | `testOn = true`, `cycleAt = now`, play cycle (as row 4, `cycle.wav` only) |
+| T2 | `testOn` and `now − cycleAt ≥ 5000` | next cycle, as row 6 |
+| T3 | `testOn` and (not `T`, or state became ARMED or FLAMEOUT) | `testOn = false`, `stopPlayback(AUDIO_IMMEDIATE)` |
+| — | always | `testPrev = T` |
+
+Row 0 doesn't stop a test (the test works while OFF). A real alarm (row 4)
+can't start during a test because the test only runs while not ARMED.
+
+Notes:
+
+- Row 2 runs during FLAMEOUT too, but row 6 still runs: the alarm keeps
+ sounding through a telemetry loss (FR-020a).
+- In FLAMEOUT, RPM between `flRpm` and `armRpm` (startup range during an
+ auto-restart) breaks `armSince` without starting anything new
+ (FR-017).
+- After DISARMED by Cut, row 3 needs the switch out of Cut **and** RPM held
+ at or above `armRpm` (User Story 5).
+- `I` = `AUDIO_IMMEDIATE`, `Q` = `AUDIO_QUEUE`. Missing files: [contracts/audio-events.md](contracts/audio-events.md).
+
+## Window display state (derived each frame, no extra storage)
+
+| Shown | When |
+| --- | --- |
+| TEST | `testOn` (takes precedence over OFF and DISARMED) |
+| OFF | `state = OFF` |
+| NO SENSOR | active, sensor entry missing |
+| NO TELEMETRY | active, `lost`, not FLAMEOUT |
+| DISARMED / ARMED | `state` |
+| FLAMEOUT | `state = FLAMEOUT`; with "NO TELEMETRY" note when `lost` |
diff --git a/specs/003-flameout-alarm/design/mockup.py b/specs/003-flameout-alarm/design/mockup.py
new file mode 100644
index 0000000..637b7b6
--- /dev/null
+++ b/specs/003-flameout-alarm/design/mockup.py
@@ -0,0 +1,105 @@
+"""Generate the Flameout Alarm RPM window mockup (SVG, Lua coordinates)."""
+import sys
+
+FACE = "rgb(18,20,24)"
+CYAN = "rgb(0,190,255)"
+UNLIT_FILL = "rgb(34,38,44)"
+UNLIT_EDGE = "rgb(70,74,80)"
+YELLOW = "rgb(255,225,0)"
+WHITE = "rgb(255,255,255)"
+GREY = "rgb(170,170,170)"
+RED = "rgb(255,45,30)"
+FONT = "font-family='Arial, Helvetica, sans-serif' font-weight='bold'"
+
+N = 12 # segments
+X0, X1 = 4, 146 # sweep extent
+GAP = 2
+
+
+def yb(t): # bottom edge: rises steeply on the left, flattens on the right
+ return 22 + 40 * (1 - t) ** 2.2
+
+
+def h(t): # segment height grows toward the high end
+ return 6 + 12 * t
+
+
+def sweep(ox, oy, lit, lit_col, idle_frac):
+ w = (X1 - X0 - GAP * (N - 1)) / N
+ out = []
+ for i in range(N):
+ xa = X0 + i * (w + GAP)
+ xb = xa + w
+ ta, tb = (xa - X0) / (X1 - X0), (xb - X0) / (X1 - X0)
+ pts = [(xa, yb(ta)), (xb, yb(tb)), (xb, yb(tb) - h(tb)), (xa, yb(ta) - h(ta))]
+ p = " ".join(f"{ox + x:.1f},{oy + y:.1f}" for x, y in pts)
+ if i < lit:
+ out.append(f" ")
+ else:
+ out.append(f" ")
+ # idle marker: a yellow bar across the sweep, poking out above and below
+ xi = X0 + idle_frac * (X1 - X0)
+ ti = idle_frac
+ out.append(f" ")
+ return "\n".join(out)
+
+
+def window(ox, oy, title, body, wh=68):
+ return (f"{title} \n"
+ f" \n{body}")
+
+
+def double_armed(ox, oy):
+ rpm, full, idle = 112400, 140000, 35000
+ lit = round(N * rpm / full)
+ b = sweep(ox, oy, lit, CYAN, idle / full)
+ b += (f"\nARMED "
+ f"\n112,400 "
+ f"\nRPM ")
+ return window(ox, oy, "Double size: armed", b)
+
+
+def double_flameout(ox, oy):
+ full, idle = 140000, 35000
+ b = sweep(ox, oy, 0, CYAN, idle / full)
+ b += (f"\n "
+ f"\nFLAMEOUT "
+ f"\n4,800 "
+ f"\nRPM ")
+ return window(ox, oy, "Double size: flameout", b)
+
+
+def single(ox, oy):
+ rpm, full, idle = 112400, 140000, 35000
+ bx, bw, by, bh = 4, 84, 8, 8
+ b = (f" "
+ f"\n "
+ f"\n "
+ f"\n112,400 "
+ f"\nARMED ")
+ return window(ox, oy, "Single size: armed", b, 23)
+
+
+W, H = 330, 200
+parts = [double_armed(10, 20), double_flameout(170, 20), single(10, 120)]
+note = (f""
+ "Mockup in Lua coordinates (150 x 68 / 150 x 23). "
+ f""
+ "Fonts approximate FONT_BIG / FONT_MINI. "
+ f""
+ "Yellow bar = idle RPM. Full scale = 4 x idle. ")
+svg = (f"\n \n"
+ + "\n".join(parts) + "\n" + note + "\n \n")
+with open(sys.argv[1], "w", encoding="utf-8", newline="\n") as f:
+ f.write(svg)
diff --git a/specs/003-flameout-alarm/design/rpm-window-mockup.svg b/specs/003-flameout-alarm/design/rpm-window-mockup.svg
new file mode 100644
index 0000000..f5a1260
--- /dev/null
+++ b/specs/003-flameout-alarm/design/rpm-window-mockup.svg
@@ -0,0 +1,48 @@
+
+
+Double size: armed
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ARMED
+112,400
+RPM
+Double size: flameout
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+FLAMEOUT
+4,800
+RPM
+Single size: armed
+
+
+
+
+112,400
+ARMED
+Mockup in Lua coordinates (150 x 68 / 150 x 23). Fonts approximate FONT_BIG / FONT_MINI. Yellow bar = idle RPM. Full scale = 4 x idle.
+
diff --git a/specs/003-flameout-alarm/plan.md b/specs/003-flameout-alarm/plan.md
new file mode 100644
index 0000000..e545fa3
--- /dev/null
+++ b/specs/003-flameout-alarm/plan.md
@@ -0,0 +1,178 @@
+# Implementation Plan: Flameout Alarm
+
+**Branch**: `feature/003-flameout-alarm` (merges to `develop`, then `main` on release) | **Date**: 2026-10-04 | **Spec**: [spec.md](spec.md)
+
+**Input**: Feature specification from `specs/003-flameout-alarm/spec.md`
+
+## Summary
+
+A new app, **Flameout Alarm** (`src/Apps/AG-FlmOt.lua`), that watches a
+turbine's RPM telemetry sensor and sounds an urgent, repeating alarm when
+the engine spools down after it has been running. It arms only once RPM
+has held at or above an arming threshold (a percentage below the typed-in
+ECU idle) for the arming time, so start-ups, the start overshoot, throttle
+chops and Cut-switch shutdowns never alarm. A double-size telemetry window
+shows RPM as a segmented sweep bar with an idle marker and a large number.
+
+Approach:
+
+- **One app file, no lib modules.** Nothing here has a second user yet
+ (Speed Gauge's R2 rule), so the install is just the script and its asset
+ folder (FR-033).
+- **A four-state machine on a 100 ms tick** (OFF, DISARMED, ARMED,
+ FLAMEOUT) with telemetry validity tracked alongside, using one timestamp
+ per condition. Cut is checked first and wins (research R8).
+- **Each alarm cycle is one 5.000 s WAV** (callout plus lock tone) played
+ with `AUDIO_IMMEDIATE`, which pre-empts other apps' speech; the
+ telemetry-lost variant is a second 5 s file. `stopPlayback` silences it
+ at once (R2–R4).
+- **Sounds generated by a new script**, `tools/voice/make_flameout.py`,
+ reusing Speed Gauge's voice helpers without changing them. The urgent
+ pitch, EQ and compression are done in Python, no ffmpeg (R5).
+- **Window drawn from precomputed segment geometry** with opaque renderer
+ fills, the cheap kind of drawing per Speed Gauge's measurements (R11).
+- **Repeatable testing without an engine**: an emulator RPM sensor plus a
+ debug `SIM` mode that plays scripted RPM profiles (R12), and a **test
+ alarm switch** that plays the real alarm on the transmitter, where sound
+ can actually be heard (R13).
+
+## Technical Context
+
+**Language/Version**: Lua 5.3.1 on the transmitter (32-bit integers and floats). Sound generator: Python 3.9+ (3.9.13 installed)
+
+**Primary Dependencies**: JETI DC/DS Lua API v1.5 as declared in `types/jeti.lua`: `system` (`getSensors`, `getSensorValueByID`, `getInputsVal`, `playFile`, `stopPlayback`, `playBeep`, `vibration`, `registerForm`, `registerTelemetry`, `pSave`/`pLoad`, `getTimeCounter`, `getDeviceType`, `getCPU`), `form`, `lcd` (incl. `lcd.renderer`), Jeti `io` (`io.open`/`io.close`). All are already declared; no new stubs. Generator only (not deployed): `piper-tts` (installed) with `en_US-amy-medium`, and `tools/voice/make_voice.py`'s helpers
+
+**Storage**: `system.pSave`/`pLoad`, per model, 17 keys ([data-model.md](data-model.md)). No files written by the app. Five generated WAVs plus `CREDITS.txt` in `src/Apps/AG-FlmOt/`, committed, CC BY-SA 4.0
+
+**Testing**: `python tools/check.py`; LuaLS diagnostics; the generator's self-check; emulator scenarios with Emulated Telemetry and `SIM` profiles ([quickstart.md](quickstart.md)); transmitter checks for sound and layout; bench test with a real ECU
+
+**Target Platform**: JETI DS-24 II / DC-24 II, firmware 6.03+ (emulator 6.04). Transmitter window sizes 150 × 68 (double) and 150 × 23 (single); emulator 157 × 127 / 157 × 60 with the title inside. The alarm works on any DC/DS transmitter; the sweep needs a DS-24 II
+
+**Project Type**: Embedded transmitter app (single Lua script plus asset folder) and a developer-side sound generator
+
+**Performance Goals**: Alarm starts within detection delay + 0.5 s (SC-001); cycle every 5 s ± 0.3 s with no gap (SC-012); relight clears within arming time + 0.5 s (SC-011); display follows RPM within 1 s (SC-013); `loop()` near zero when OFF (FR-034); no CPU-limit errors over 30 minutes (SC-008)
+
+**Constraints**: No allocation in `loop()` or the print function, except rebuilding `rpmText` when RPM / 100 changes; call `system.getSensor*` through `system` each time; `lcd` only in the print function; `form` only while the form is open; ≤ 30 persisted keys; ≤ 200 file-level locals (group constants in tables); no `os`/`debug`/`coroutine`/`bit32`; never `registerControl`/`setControl`/`setProperty`; intbox values ≤ 32,767 (RPM entered in hundreds)
+
+**Scale/Scope**: App about 700–900 lines (Speed Gauge is 1,643); generator about 250 lines; 5 WAVs
+
+All unknowns are resolved in [research.md](research.md) or turned into
+transmitter checks with a fallback:
+
+- gap between `playBeep` repeats (R6; fallback timing adjustable);
+- the firmware's own telemetry "valid" timeout (R9; measured, documented);
+- CPU cost of the sweep (R11; drop outlines, then straight bar);
+- which ECUs beyond JetCat, Xicoy, KingTech and Swiwin-via-VSpeak expose RPM
+ as a sensor (R1; README lists what is known).
+
+## Constitution Check
+
+*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
+
+| Principle | Status | How the design complies |
+| --- | --- | --- |
+| I. Nothing flight-critical | PASS | Reads one sensor and one switch, plays audio, vibrates, draws. No `registerControl`, `setControl`, `setProperty`, gpio or serial. The Cut switch is only read. If the app stops, the model and the ECU are unaffected (FR-032) |
+| II. Everything local | PASS | All symbols `local`; constants, colors and segment geometry grouped in tables to stay well under 200 locals; one final `return` |
+| III. Transmitter APIs only | PASS | Every call listed in Technical Context is declared in `types/jeti.lua`; Jeti `io` function style; `math`/`string` only. The generator runs on the PC |
+| IV. State resets on model switch | PASS | Session state built in `init()`; 17 `pSave` keys (ints, one string, two SwitchItems); RPM and seconds stored as scaled integers; load clamps and validates |
+| V. Stable names, UTF-8 | PASS | `AG-FlmOt.lua` (8.3, permanent at release), assets in `AG-FlmOt/`, absolute `/Apps/AG-FlmOt/...` paths; labels avoid characters outside the charset ("x" for "×") |
+| VI. Keep loop() light | PASS | 100 ms tick; immediate return when OFF; `getSensorValueByID`; thresholds precomputed; file checks only in `init()`; cached strings; form rows touched only while `formOpen` |
+| VII. Shared code in lib, stateless | PASS | No lib module added or changed, so no other app needs re-verification. `make_voice.py` (a tool, not a lib) is imported but not modified |
+| VIII. License and credit | PASS | Original app, not derived from another author's code: SPDX header, no third-party credit needed for code. Piper (MIT) and Amy (CC BY-SA 4.0) credited in `AG-FlmOt/CREDITS.txt`, `CREDITS.md`, the README and the settings footer (FR-028). The ECU telemetry app in R1 was studied for facts only, not copied. The Haltech screenshot stays local; the committed mockup is original |
+
+**Post-design re-check (2026-10-04):** PASS. The contracts add no API
+beyond the table. Two patterns need care during implementation:
+`form.setValue`/`setProperties` from `loop()` only while `formOpen`
+(Speed Gauge R17), and the `SIM`/`DEBUG_CPU` debug constants must be off
+in any release build.
+
+## Project Structure
+
+### Documentation (this feature)
+
+```text
+specs/003-flameout-alarm/
+├── spec.md
+├── plan.md # this file
+├── research.md # R1 ECU telemetry; R2–R12 planning decisions
+├── data-model.md # settings, derived values, session state, transitions
+├── quickstart.md # validation: static, emulator, transmitter, engine
+├── contracts/
+│ ├── settings-form.md # rows, order, validation
+│ ├── telemetry-window.md # layouts, colors, states
+│ └── audio-events.md # sound files, events, fallback, vibration
+├── design/
+│ ├── rpm-window-mockup.svg # original mockup, Lua coordinates
+│ └── mockup.py # regenerates it
+├── checklists/requirements.md
+└── tasks.md # /speckit-tasks
+```
+
+### Source Code (repository root)
+
+```text
+src/Apps/
+├── AG-FlmOt.lua # NEW: the app (settings form, state machine, print function)
+└── AG-FlmOt/ # NEW: generated by tools/voice/make_flameout.py, committed (CC BY-SA 4.0)
+ ├── cycle.wav
+ ├── cycletl.wav
+ ├── tlost.wav
+ ├── armed.wav
+ ├── relit.wav
+ └── CREDITS.txt
+
+tools/voice/
+├── make_flameout.py # NEW: urgent callouts, lock tone, cycle files (R5)
+├── make_voice.py # unchanged (Speed Gauge); helpers imported
+└── README.md # add a Flameout Alarm section
+
+tools/emulator/sensors.json # add the "Turbine" RPM sensor (R12)
+
+README.md # apps table row; advisory note; ECU support list (FR-035)
+CREDITS.md # Flameout Alarm sounds: Piper, Amy (CC BY-SA 4.0)
+CLAUDE.md # apps table row
+docs/jeti-api-notes.md # findings from transmitter testing (beep gap, valid timeout)
+```
+
+Inside `AG-FlmOt.lua`, sections follow `docs/examples/style/HELLO.lua`:
+constants (paths, colors, layout) → settings, keys and limits → derived
+values and recompute → session state → audio helpers → state machine →
+settings form → print function and layouts → `init` / `loop` / `destroy` →
+`return { ... }`. `APP_VERSION` starts at "0.1.0".
+
+**Structure Decision**: The repo's standard single-app layout (constitution
+V). The generator sits in `tools/voice/` next to Speed Gauge's because it
+shares its helpers and voice model. No `lib/` module: the only candidates
+(file check, number formatting) are a few lines each and have no second
+user that needs them shared.
+
+## Implementation order
+
+For `/speckit-tasks`. Each step leaves `check.py` passing.
+
+1. **Sound generator** (R5): `make_flameout.py` with DSP, lock tone, cycle
+ assembly and self-check; generate and listen on the PC; README section.
+2. **App skeleton**: header, constants, settings keys with load/clamp/
+ validate, recompute, `init` registering form and window, OFF window.
+3. **Settings form** ([contract](contracts/settings-form.md)): sensor list
+ with "(not found)", scale, live RPM, idle/max, thresholds with refusal,
+ times, Cut switch, confirmations, test switch, enable rules, voice
+ status.
+4. **State machine** ([data-model.md](data-model.md)): tick, Cut first,
+ validity and loss, arm, detect, relight, cycle timing.
+5. **Audio** ([contract](contracts/audio-events.md)): file checks, cycle
+ and telemetry-lost files, stop, confirmations, fallback beeps,
+ vibration, test alarm (R13).
+6. **Window** ([contract](contracts/telemetry-window.md)): layout selection,
+ segment geometry, double, single and text layouts, number cache.
+7. **Test support** (R12): emulator sensor in `sensors.json`; `SIM`
+ profiles; `DEBUG_CPU`.
+8. **Docs and credits**: README (row, advisory, ECU list, idle and
+ threshold guidance per FR-035), CREDITS.md, CLAUDE.md table, generator
+ README.
+9. **Validation**: quickstart sections 1–2 in the emulator; sections 3–4
+ on the transmitter and the bench (manual, reported by the user).
+
+## Complexity Tracking
+
+No constitution violations to justify.
diff --git a/specs/003-flameout-alarm/quickstart.md b/specs/003-flameout-alarm/quickstart.md
new file mode 100644
index 0000000..6b25487
--- /dev/null
+++ b/specs/003-flameout-alarm/quickstart.md
@@ -0,0 +1,88 @@
+# Quickstart: validating Flameout Alarm
+
+How to prove the app meets the spec, from static checks to a real engine.
+Behavior details are in [data-model.md](data-model.md) and the
+[contracts](contracts/); this guide says what to run and what to expect.
+
+## Prerequisites
+
+- `python tools/check.py` and LuaLS (VS Code) set up as for Speed Gauge.
+- JETI Studio with the DS-24 II emulator (firmware 6.04). App folder:
+ `%LOCALAPPDATA%\JETI-Studio\Emulator\Apps`.
+- LeonAirRC's Emulated Telemetry app installed there, with
+ `tools/emulator/sensors.json` copied to `Apps/EmulatedTelemetry/` after
+ adding the "Turbine" RPM sensor (research R12).
+- For sound and the window's real proportions: the DS-24 II, a test model,
+ and the generated sounds (`python tools/voice/make_flameout.py --model
+ \en_US-amy-medium.onnx`).
+
+## Deploy
+
+```powershell
+$dst = "$env:LOCALAPPDATA\JETI-Studio\Emulator\Apps" # or the SD card's \Apps
+Copy-Item src\Apps\AG-FlmOt.lua $dst
+Copy-Item src\Apps\AG-FlmOt $dst -Recurse -Force
+```
+
+No lib modules: the install is just these two items (FR-033, SC-007).
+
+## 1. Static checks
+
+| Check | Expect |
+| --- | --- |
+| `python tools/check.py` | 0 errors |
+| LuaLS diagnostics on `AG-FlmOt.lua` | no errors |
+| `python tools/voice/make_flameout.py ...` | self-check passes: 5 WAVs, both cycle files exactly 5.000 s |
+| Search the app for `registerControl`, `setControl`, `setProperty` | none (SC-006) |
+
+## 2. Emulator scenarios
+
+Add the app and Emulated Telemetry in Applications → User Applications.
+Place the app's window at double size, then repeat the display checks at
+single size. Audio and vibration show as console lines.
+
+| # | Scenario | Steps | Expect | Spec |
+| --- | --- | --- | --- | --- |
+| 1 | Default off | Fresh model, add app | Window shows OFF; no console audio lines in any scenario below until enabled | FR-001, SC-009 |
+| 2 | Enable blocked | Tick Monitoring with nothing set | Box unticks; hint lists sensor, Cut switch, idle | FR-003 |
+| 3 | No sensors | Emulated Telemetry not running, open settings | "No telemetry yet..." hint | FR-004 |
+| 4 | Setup | Pick Turbine / RPM, Cut switch, idle 35.0; enable | Live RPM follows the slider; thresholds read 31.5k / 24.5k; under 3 minutes | FR-002–FR-010, SC-005 |
+| 5 | Threshold rules | Set flameout 92% with arming 90%; max RPM 30.0 | Both refused with hint | FR-007, FR-031b |
+| 6 | Arm | Slider above 31,500 for 3 s, Cut off | DISARMED → ARMED | FR-014 |
+| 7 | Chop | Armed; slider to 30,000 | No alarm | US2 |
+| 8 | Glitch | Armed; slider below 24,500 for under 1 s | No alarm | FR-015 |
+| 9 | Flameout | Armed; slider to 0 and hold | Alarm within 1.5 s: `cycle.wav` immediate + vibration lines, then every 5 s; FLAMEOUT banner | FR-015, FR-023, SC-001, SC-012 |
+| 10 | Restart wobble | In alarm; slider 10,000–30,000 up and down | Alarm continues, no new event | FR-017, SC-011 |
+| 11 | Relight | In alarm; slider above 31,500 for 3 s | Stop line, ARMED within 3.5 s | FR-018, SC-011 |
+| 12 | Cut | In alarm; flip Cut | Stop line at once; DISARMED; slider low: silent | FR-016, US5 |
+| 13 | Loss while armed | Armed; slider fully down (invalid) | After 2 s: `tlost.wav` line, NO TELEMETRY; never `cycle.wav` | FR-020, SC-004 |
+| 14 | Loss in alarm | In alarm; slider fully down | Alarm continues; next cycle `cycletl.wav` once; "NO TELEMETRY" under the number | FR-020a |
+| 15 | Return | Armed, lost; slider back above 31,500 | ARMED, no alarm | FR-021 |
+| 16 | Sensor gone | Remove the Turbine sensor from sensors.json, restart | NO SENSOR; settings show "(not found)" | FR-022 |
+| 17 | Per model | Enable on model A; load model B; restart emulator | B is OFF; A keeps its settings | FR-011, SC-010 |
+| 18 | Missing voice | Rename `cycle.wav`, restart | Settings: "Voice files missing"; alarm uses `playBeep` lines | FR-029 |
+| 19 | Display | Step through every state at double and single size | Matches the [window contract](contracts/telemetry-window.md); nothing clipped; bar reaches the idle marker at 35,000 | FR-031, SC-013 |
+| 19a | Test alarm | Assign a test switch; monitoring off; switch on 12 s, then off | `cycle.wav` and vibration lines at 0, 5 and 10 s, window TEST; stop line when switched off. Armed: switch does nothing. Switch on at model load: nothing until toggled | FR-030a |
+| 20 | Simulated profiles | `SIM` on; run each profile | Normal flight: zero alarms (SC-002, SC-003); flameout and restart profiles as in 9–11 | R12 |
+| 21 | CPU | `DEBUG_CPU` on; 30 minutes incl. an alarm | No CPU limit errors; note the worst draw figure | SC-008 |
+
+## 3. Transmitter (test model, engine not running)
+
+| # | Check | Expect |
+| --- | --- | --- |
+| 22 | Window | Screenshot (`Screen00n.png`) at double and single size matches the mockup's proportions |
+| 23 | Alarm sound | Hold the test alarm switch (no engine, no sensor needed): callout then lock tone, no gap, new callout every 5 s; urgent and clear over background noise; vibration on both sticks |
+| 24 | Pre-emption | Run Speed Gauge too; trigger while it speaks: alarm cuts in. Trigger its stall warning during the alarm: alarm resumes at the next cycle |
+| 25 | Stop | Relight and Cut mid-callout and mid-beep: silence at once |
+| 26 | Fallback beeps | Remove the cycle files: beeps are clearly an alarm; note the gap between repeats |
+| 27 | Loss timing | Switch the receiver off while armed: note seconds until "Engine telemetry lost" (firmware timeout + setting) |
+| 28 | Install | Copy only `AG-FlmOt.lua` and `AG-FlmOt/` to a card with no other AG- apps: loads and runs | SC-007 |
+
+## 4. Real engine (bench, test model; never a model flying that day)
+
+With the ECU's telemetry converter connected: check the sensor's label and
+unit, set idle from the ECU setup and compare with Live RPM at idle.
+Run a normal start: confirm the overshoot and decay never drop below the
+arming threshold and no alarm sounds (SC-002). Shut down with Cut: silent.
+Record a Jeti log of a start and a shutdown to refine the defaults (spec
+Assumptions) and the simulated profiles.
diff --git a/specs/003-flameout-alarm/research.md b/specs/003-flameout-alarm/research.md
new file mode 100644
index 0000000..b952ee6
--- /dev/null
+++ b/specs/003-flameout-alarm/research.md
@@ -0,0 +1,413 @@
+# Research: Flameout Alarm
+
+Decisions behind [plan.md](plan.md). R1 was researched during
+specification (2026-10-04); R2–R12 during planning. Each gives the decision,
+why, and what else was considered. API facts come from the JETI Lua API v1.5
+(`docs/vendor/jeti-api.md`, generated locally by `tools/pdf2md.py`) and
+`docs/jeti-api-notes.md`.
+
+| # | Topic |
+| --- | --- |
+| R1 | How ECUs deliver RPM |
+| R2 | Alarm audio: one 5-second file per cycle, played immediately |
+| R3 | Stopping the alarm, pre-empting and being pre-empted |
+| R4 | Telemetry-lost callout during the alarm |
+| R5 | Alarm voice and lock tone generation |
+| R6 | Missing audio files: fallback beeps |
+| R7 | Vibration |
+| R8 | Engine monitor state machine and timing |
+| R9 | Reading RPM: validity, sensor not found, units |
+| R10 | Settings: number entry, thresholds, Cut switch |
+| R11 | RPM window drawing and cost |
+| R12 | Testing without an engine: emulator sensor and simulated profiles |
+| R13 | Test alarm switch |
+
+## R1. How ECUs deliver RPM to a Jeti transmitter
+
+**Question:** In what form does each turbine ECU send RPM over Jeti
+telemetry, so the app can read it (spec FR-002, FR-006a, FR-010)?
+
+**Finding: RPM arrives as a plain integer RPM value on a standard Jeti EX
+sensor.** Where the data reaches the transmitter as an EX sensor, the
+value is the shaft speed in RPM (e.g. 112400), not thousands. The best
+evidence is the open-source Jeti ECU telemetry app by Thomas Ekdahl
+([thomasekdahlN/jeti](https://github.com/thomasekdahlN/jeti)), which
+supports JetCat, KingTech, Xicoy, VSpeak, Digitech and CB-Electroniks
+converters. Its display divides the RPM value by 1000 to show "112K" for
+every converter (`ecu/lib/window4.lua`), and its per-converter configs
+(`ecu/converter///config.jsn`) map RPM to a sensor
+parameter with no scaling.
+
+The Jeti Lua API already applies the sensor's decimals: a sensor entry has
+`value`, `decimals`, `unit` and `valid` (`types/jeti.lua`). So "raw numeric
+value" is right in practice: the app reads a number and compares it with
+RPM thresholds. The sensor scale setting (FR-006a, default ×1) stays as a
+safety valve for any converter found to send a scaled value.
+
+### Per manufacturer
+
+| Manufacturer | Path to Jeti | RPM as EX sensor? | RPM param* | Status sensor? | Confidence |
+| --- | --- | --- | --- | --- | --- |
+| JetCat | JetCat's own Jeti converter, VSpeak, Digitech or CB-Electroniks converter | Yes | 2 | Yes, param 3: signed code, 13 = Run, negatives = faults | High |
+| Xicoy (V6/V10 FADEC, also JetsMunt) | Xicoy telemetry adapter v5 (factory-set for Jeti) | Yes: sensor group "Turbine" with 6 measures (RPM, EGT, ECU battery, throttle %, pump V, fuel %) | 2 | Not as an EX sensor; status shows in the JetiBox screen emulation | High (Xicoy manual) |
+| KingTech (G1–G5) | KingTech telemetry unit (lists Jeti support), VSpeak or Digitech converter | Yes | 2 | Not in the KingTech converter config | Medium |
+| Swiwin (ECU V3) | VSpeak converter for Swiwin (EX-Sensor / EX-Bus); or the ECU's own telemetry port wired to a receiver's Ext port set to "Jetibox" | VSpeak: yes. Direct: unclear, may be JetiBox text only | — | VSpeak: yes (coded as a numeric value) | Low for direct |
+| JetCentral | JetCentral Jeti Telemetry Adapter V2 | Unclear: the adapter ships with its own Lua app (`JCHDT.lc`, firmware ≥ 4.27); its values may not appear as ordinary sensors | — | Shown by its app | Low |
+| Enjet Power | Retailers list "telemetry: Jeti, Futaba, FrSky (external module)" | Unknown: no Jeti documentation found | — | Unknown | Low |
+
+\* Parameter number within the converter's sensor, from the Ekdahl configs.
+The app doesn't depend on it: the pilot picks the sensor by its label.
+
+### Other observations
+
+- **Update rate** isn't documented by any manufacturer. Jeti EX sensors
+ typically refresh several times a second; the 1.0 s detection delay and
+ 2 s telemetry-loss delay assume at least about 2 updates per second.
+ Check on the transmitter with a real converter (plan / test).
+- **Sensors appear 30–60 s after power-up** with the Xicoy adapter (its
+ manual). Setup's "no sensors yet" message (FR-004) should say to wait.
+- **The startup false-alarm problem is real.** Xicoy's adapter disables its
+ own low-RPM alarm until the tank drops below 98%, "to avoid the alarms of
+ Low RPM ... to be triggered during startup phase". That is the problem
+ the app's arming solves.
+- **Swiwin and VSpeak:** users report the Swiwin ECU misbehaving with the
+ transmitter's "Auto" output period; 11–13 ms fixes it. Not our concern,
+ but worth a README note for Swiwin users.
+- **ECU status codes** (JetCat: 13 Run, −7/−29/−30 Low rpm, −14 Failsafe;
+ Xicoy: 8 Running, 15 Flameout) could confirm a flameout faster than RPM,
+ but codes differ per brand and Xicoy doesn't send status as a sensor.
+ Stays out of v1 (spec Assumptions).
+
+### Decision
+
+- Read RPM from a pilot-selected EX sensor as plain RPM; keep the scale
+ setting at default ×1.
+- Supported: any ECU or converter that shows RPM as a normal telemetry
+ sensor (JetCat, Xicoy, KingTech via its unit or a converter, Swiwin via
+ VSpeak). Not supported: setups whose RPM is only visible in a JetiBox
+ screen or inside another vendor's Lua app. The README says so and lists
+ what is known per brand.
+- Open for testing: JetCentral adapter V2, Enjet, Swiwin direct. If any of
+ these turns out to publish RPM as a normal sensor, it just works.
+
+### Related limit found: number entry range
+
+`form.addIntbox` takes values from −32768 to 32767 (`types/jeti.lua`).
+Idle RPM (typically 30,000–40,000) and max RPM (100,000–160,000) don't fit
+as plain integers. **Decision (2026-10-04):** settings enter them in
+steps of 100 RPM, shown in thousands with one decimal ("35.0" = 35,000
+RPM): the stored integer is RPM / 100 (max 1,600 for 160,000 RPM). See spec
+FR-006.
+
+### Sources
+
+- [thomasekdahlN/jeti: Jeti ECU telemetry Lua app](https://github.com/thomasekdahlN/jeti)
+- [Xicoy telemetry adapter v5 user's guide (PDF)](https://www.xicoy.com/downloads/Telemet1_1_en.pdf)
+- [Xicoy telemetry adapter product page](https://www.xicoy.com/catalog/product_info.php?products_id=362)
+- [VSpeak ECU converter for Swiwin](https://www.vspeak-modell.de/en/ecu-converter/swiwin)
+- [VSpeak ECU converter for JetCat (RPM Jets)](https://rpmjets.com/products/ecu-converter-jetcat)
+- [JetCentral Jeti telemetry V2 Lua](https://jetcentral.com.mx/telemetry/)
+- [KingTech telemetry unit G2–G5 (Pacific RC Jets)](https://pacificrcjets.com/products/kingtech-telemetry-unit)
+- [Swiwin + Jeti + VSpeak (RCU Forums)](https://www.rcuniverse.com/forum/rc-jets-120/11710796-swiwin-jeti-v-speak-=-loosing-my-mind.html)
+- [Swiwin turbine telemetry adapter from VSpeak (RCU Forums)](https://www.rcuniverse.com/forum/rc-jets-120/11684464-swiwin-turbine-telemetry-adapter-vspeak.html)
+- [Enjet Power E100 (Helidirect)](https://www.helidirect.com/products/enjet-power-e100-turbine)
+
+## R2. Alarm audio: one 5-second file per cycle, played immediately
+
+**Decision:** Each alarm cycle is **one pre-built WAV of exactly 5.000 s**:
+the urgent "Flameout! Flameout! Flameout!" callout followed by the lock tone
+filling the rest. At the start of each cycle `loop()` calls
+`system.playFile(CYCLE, AUDIO_IMMEDIATE)`; the next cycle starts when
+`now - cycleAt >= 5000`.
+
+**Why:**
+
+- **No gap or overlap (SC-012)** without timing two files from `loop()`.
+ Voice-to-tone timing is fixed inside the file; only the 5-second rhythm
+ depends on `loop()`, which runs every 20–30 ms (checked on the 100 ms
+ logic tick: jitter well inside ±0.3 s).
+- **`AUDIO_IMMEDIATE` "starts immediately in the foreground" and "stops any
+ previous foreground playback"** (API v1.5, `playFile`). So a cycle starts
+ on time even if another app is speaking (FR-025), and if the previous
+ cycle's file runs a few ms long, the new one cuts its tail instead of
+ queuing behind it.
+- `AUDIO_QUEUE` would wait behind any other app's queued speech: rejected.
+- A short looping lock-tone file restarted by `loop()` would need precise
+ sub-second scheduling and could leave gaps: rejected.
+
+## R3. Stopping the alarm, pre-empting and being pre-empted
+
+**Decision:** Relight and Cut call `system.stopPlayback(AUDIO_IMMEDIATE)`,
+which "stops only foreground playback and clears the foreground queue". All
+alarm sound stops at once, mid-callout or mid-beep (FR-016, edge case). It
+also drops any other app's queued foreground speech at that moment, which is
+acceptable at a relight or shutdown.
+
+**Known limits (documented, not solved):**
+
+- **Another app's `AUDIO_IMMEDIATE` cuts our cycle.** Speed Gauge plays its
+ stall and overspeed warnings that way, and a dead-stick glide can trigger
+ a stall warning. The alarm then resumes at the next 5-second boundary, so
+ at most one cycle is shortened. Quickstart checks this with both apps.
+- **Transmitter alarms can interrupt foreground playback** ("playback can be
+ interrupted by alarms", API v1.5). Native alarms (e.g. low receiver
+ voltage) may briefly cut the alarm; again it resumes at the next cycle.
+- `AUDIO_BACKGROUND` is "not interruptible by alarms", but it also isn't
+ stopped by `stopPlayback(AUDIO_IMMEDIATE)` and would overlap other apps'
+ speech instead of pre-empting it. Rejected: an alarm that talks over a
+ callout is harder to understand than one that replaces it.
+
+## R4. Telemetry-lost callout during the alarm
+
+**Decision:** A second cycle file, **`cycletl.wav`** (5.000 s), holds the
+flameout callout, then "Engine telemetry lost", then a shorter lock tone.
+When telemetry passes the loss delay during the alarm, the app sets a flag
+and plays `cycletl.wav` instead of `cycle.wav` at the **next** cycle
+boundary, once (FR-020a).
+
+**Why:** It keeps the 5-second rhythm (SC-012) and speaks the warning
+"between two alarm cycles" without stopping or restarting the alarm.
+Playing `tlost.wav` on its own at the boundary would shift the following
+cycle by the phrase length. The generator checks that both parts fit
+(R5).
+
+While Armed (no alarm), the loss callout is `tlost.wav` with
+`AUDIO_IMMEDIATE` (FR-020).
+
+## R5. Alarm voice and lock tone generation
+
+**Decision:** A new script, **`tools/voice/make_flameout.py`**, generates
+the app's sounds into `src/Apps/AG-FlmOt/`. It imports the helpers in
+`tools/voice/make_voice.py` (`synthesize`, `trim`, `normalize`, `resample`,
+`write_wav`, `write_text`) and leaves that script unchanged, so Speed
+Gauge's voice set is unaffected. Files, all mono 16-bit 22.05 kHz (verified
+to play on the transmitter with Speed Gauge 0.3.0):
+
+| File | Content | Length |
+| --- | --- | --- |
+| `cycle.wav` | "Flameout!" ×3 (0.12 s apart), then lock tone | exactly 5.000 s |
+| `cycletl.wav` | "Flameout!" ×3, "Engine telemetry lost", then lock tone | exactly 5.000 s |
+| `tlost.wav` | "Engine telemetry lost" | ≈ 1.3 s |
+| `armed.wav` | "Flameout alarm armed" | ≈ 1.3 s |
+| `relit.wav` | "Engine relit" | ≈ 1 s |
+| `CREDITS.txt` | Piper (MIT) and Amy (CC BY-SA 4.0) | — |
+
+The urgent delivery (spec FR-026) is done **in Python, without ffmpeg**
+(not installed here):
+
+- **Speed and pitch together.** The draft's chain was Piper length scale
+ 0.70, then ffmpeg `asetrate ×1.07` (raises pitch and speeds up 7%) and
+ `atempo 0.9346` (slows back down without changing pitch). Equivalent
+ without a time-stretch: synthesize at length scale 0.70 × 1.07 = **0.749**
+ (7% slower), then resample so it plays 7% faster (`asetrate`). Net speed
+ is the draft's 0.70, pitch is up about one semitone. Noise scale 0.5.
+- **High-pass 150 Hz** and **+4 dB peaking at 3 kHz**: standard biquads
+ (RBJ cookbook) in plain Python.
+- **Compression**: threshold −18 dB, ratio 4, attack 3 ms, release 60 ms,
+ makeup +4 dB, as a simple peak envelope follower.
+- Trim and normalize to Speed Gauge's peak (−1 dBFS) with the existing
+ helpers.
+- **Lock tone**: 1800 Hz sine plus a 3600 Hz overtone, 45 ms on / 35 ms
+ off with 3 ms fades (12.5 beeps/s), from the approved sample, at the same
+ peak.
+
+Only the Flameout callouts are urgent; the confirmations and "telemetry
+lost" use Speed Gauge's normal speed (1.5) so they sound calm and distinct
+from the alarm.
+
+**Self-check** (exit 1 on failure): every file present; both cycle files
+exactly 110,250 samples; the voice part of `cycle.wav` ≤ 2.5 s ("about
+2 s", FR-023) and of `cycletl.wav` ≤ 4.0 s so at least 1 s of lock tone
+remains.
+
+**Alternatives:** extending `make_voice.py` with a `--set` option (touches
+the Speed Gauge tool and its self-check: rejected); installing ffmpeg (an
+extra dependency for one filter chain: rejected); the draft's samples made
+with Amy *low* (the spec asks for the same model as Speed Gauge, *medium*).
+
+## R6. Missing audio files: fallback beeps
+
+**Decision:** `init()` opens each of the five WAVs once (Speed Gauge's
+`fileExists` pattern, function-style `io`). If either cycle file is missing,
+each alarm cycle instead calls `system.playBeep(9, 1800, 120)` (10 beeps)
+at the cycle start and again 2.5 s later, keeping the on-screen FLAMEOUT
+and vibration. Settings shows "Voice files missing" (FR-029). A missing
+confirmation or `tlost.wav` is skipped silently; the screen still shows the
+state.
+
+`playBeep` takes 0–10 extra beeps, 200–10,000 Hz, 20–10,000 ms (API v1.5).
+The gap between beeps isn't documented: check by ear (quickstart).
+
+## R7. Vibration
+
+**Decision:** At the start of every alarm cycle,
+`system.vibration(false, 4)` and `system.vibration(true, 4)`: three short
+pulses on both sticks (FR-030), as in the API example for both sticks. Not
+supported on DC/DS-16, which the app doesn't target. Speed Gauge uses
+profile 4 for its stall warning; the API calls it "3 × short pulse".
+
+## R8. Engine monitor state machine and timing
+
+**Decision:** A 100 ms logic tick, as in Speed Gauge. States: `OFF`,
+`DISARMED`, `ARMED`, `FLAMEOUT`; telemetry validity is tracked alongside,
+not as a state, so "no telemetry" can overlay ARMED or FLAMEOUT. Full table
+in [data-model.md](data-model.md).
+
+- **One timestamp per condition** (`armSince`, `lowSince`, `clearSince`,
+ `invalidSince`), set when the condition starts and cleared when it
+ breaks: no counters, no allocation.
+- **Cut is checked first every tick** and wins over everything (FR-016).
+- **Telemetry loss freezes detection**: an invalid sample clears
+ `armSince`, `lowSince` and `clearSince`, so a loss can never complete a
+ flameout or a relight (FR-020, FR-021).
+- Tick period vs. the specs: alarm start ≤ detection delay + one tick
+ (100 ms) + loop jitter, inside SC-001's +0.5 s; relight clear likewise
+ for SC-011.
+
+**Alternative:** run the logic every `loop()` (20–30 ms): finer timing
+nobody needs, 3–5× the work.
+
+## R9. Reading RPM: validity, sensor not found, units
+
+**Decision:**
+
+- `system.getSensorValueByID(id, param)` each tick, always through
+ `system` (the emulator's telemetry app replaces it; Speed Gauge R3).
+ `nil` means the sensor isn't in the model's list; `valid == false` means
+ no recent data. Both count as invalid for detection.
+- `nil` shows **NO SENSOR** (FR-022); `valid == false` shows **NO
+ TELEMETRY**. Neither arms.
+- RPM = `value × scale` (FR-006a), compared with thresholds precomputed in
+ RPM when settings change.
+- **Unit check** in settings only (FR-010): accept units that read as RPM
+ ignoring case: `rpm`, `U/min`, `1/min` (German converters use the last
+ two). Anything else, including an empty unit, shows the warning; the
+ sensor stays selectable because some converters send no unit.
+- The firmware's own "valid" timeout is unknown. The telemetry-loss delay
+ counts from the first invalid sample, so the real delay is a little
+ longer than the setting. Measure on the transmitter (quickstart).
+
+## R10. Settings: number entry, thresholds, Cut switch
+
+**Decision:**
+
+- **Idle and max RPM** stored as RPM / 100 in an intbox with 1 decimal, so
+ "35.0" = 35,000 RPM (spec FR-006; intbox limit 32,767). Idle range
+ 0–150.0 (0 = not set; small turbines idle around 50,000–60,000); max
+ 0–300.0 (0 = auto, 4 × idle; small turbines reach about 240,000). Max ≤
+ idle is refused.
+- **Thresholds** as whole percentages of idle: arming 50–98 (default 90),
+ flameout 20–95 (default 70). A change that would make flameout ≥ arming
+ is refused: the box snaps back and a hint explains (FR-007). Arming % <
+ 100 keeps the arming threshold below idle by construction.
+- **Times** in tenths of a second: arming 1.0–10.0 s (3.0), detection
+ 0.3–5.0 s (1.0), telemetry loss 0.5–10.0 s (2.0).
+- **Cut switch**: `form.addInputbox(swCut, false, ...)`, non-proportional.
+ The pilot assigns it with the switch in the Cut position; Jeti stores the
+ direction in the SwitchItem, so "in Cut" is `getInputsVal(swCut) > 0.5`
+ (Speed Gauge R10's rule). A hint says how to assign it.
+- **Enable checkbox** is refused (unticked again, hint shows what's
+ missing) unless sensor, Cut switch and idle are all set (FR-003).
+- **Live RPM row** updated from `loop()` only while the form is open
+ (Speed Gauge R17's `formOpen` pattern), at most every 500 ms.
+- 17 persisted keys, well under 30 (the test switch, R13, is the 17th) ([data-model.md](data-model.md)).
+
+## R11. RPM window drawing and cost
+
+**Decision:** One telemetry window, size 0 ("auto"), so the pilot places it
+at single or double size (verified with Speed Gauge 0.3.0). The print
+function picks the layout from `(w, h)`, after Speed Gauge's emulator
+title-bar correction (`w == 157` → `h - 26`):
+
+- `h >= 45`: **double layout**, the segmented sweep of
+ [design/rpm-window-mockup.svg](design/rpm-window-mockup.svg): 12 segments,
+ bottom edge `y = 22 + 40·(1 − t)^2.2`, height `6 + 12·t`, 2 px gaps.
+- otherwise: **single layout**, the straight bar.
+
+Segment corner points are computed once per layout in `init()` (integer
+arrays, no per-frame trig or allocation). Each frame draws the segments as
+filled `lcd.renderer()` polygons at full opacity (one renderer reused, as
+Speed Gauge does) and the idle marker as a filled rectangle. Unlit segments
+get a dim fill plus a 1 px outline polyline.
+
+**Measured (2026-10-06, DS-24 II, `DEBUG_CPU`):** 5–6% of the per-call
+budget for the double-size window (5% armed, 6% with the FLAMEOUT banner),
+less than half of Speed Gauge's double-size dial. No fallback needed. Both
+window sizes confirmed on the transmitter by screenshot.
+
+**Cost (planning estimate):** Speed Gauge measured that opaque fills are cheap and
+semi-transparent anti-aliased polylines are what cost CPU. 12 polygons and
+12 thin opaque outlines should stay far below Speed Gauge's double-size
+14–18%. Measured with a `DEBUG_CPU` flag (off for release), as in Speed
+Gauge. If too costly: drop the outlines first, then fall back to the
+straight bar (spec FR-031).
+
+**Number:** "112,400" built by a small formatter only when RPM / 100
+changes, so at most once per tick; `FONT_BIG`, right-aligned under the high
+end, "RPM" in `FONT_MINI` below it.
+
+**Other transmitters:** the sweep needs `lcd.renderer` (DC/DS-24 only). On
+anything that isn't a DS-24 II (`getDeviceType()` lacks "24 II", Speed
+Gauge R12), the window shows the state and number as text. The alarm works
+everywhere.
+
+**Lit segments:** `ceil(12 · rpm / fullScale)`, capped at 12, none without
+data. At idle RPM the lit run ends at the marker. Layout constants are named
+so the design can be iterated on the transmitter.
+
+## R12. Testing without an engine: emulator sensor and simulated profiles
+
+**Decision:**
+
+1. **Emulator sensor.** Add a "Turbine" sensor to
+ `tools/emulator/sensors.json` (LeonAirRC's Emulated Telemetry): RPM,
+ unit `rpm`, 0–160,000 on a slider. The slider fully down makes it invalid,
+ which tests telemetry loss.
+2. **Simulated profiles.** A `SIM` debug constant in the app (off for
+ release, like `DEBUG_CPU`) replaces the sensor read with scripted RPM
+ profiles (time, RPM pairs in one table): normal flight with start
+ overshoot and decay, chops, flameout, auto-restart that fails then
+ relights, telemetry dropouts. That makes SC-001, SC-002, SC-003 and SC-011
+ repeatable in the emulator. Switching profiles uses the app's form; the
+ table lives in one local to respect the 200-local limit.
+3. **Audio** can only be heard on the transmitter (the emulator plays no
+ Lua audio); in the emulator the Emulated Telemetry app prints each
+ `playFile` and `vibration` call to the console, which checks timing.
+4. **Real engine**: bench test on a test model with the turbine's ECU
+ converter, never on a model flying that day (constitution verification
+ step 4).
+
+**Alternative:** a standalone test runner under plain Lua (none installed;
+the state machine would have to be split into a module just for testing).
+Rejected for v1.
+
+## R13. Test alarm switch
+
+**Decision:** An optional SwitchItem `swTest` (spec FR-030a). A **rising
+edge** (off to on) while the state is OFF or DISARMED starts a test: the
+same cycle code as a real alarm (R2, R6, R7), with `cycleAt` set and the
+window showing TEST. The test runs while the switch stays on and stops with
+`stopPlayback(AUDIO_IMMEDIATE)` when the switch turns off or the app arms.
+
+**Why:**
+
+- **The rising edge** means a switch left on at model load, or after the
+ app arms, doesn't start an alarm by itself (spec edge cases).
+- **Reusing the alarm path** tests exactly what a flameout would play,
+ including the fallback beeps when files are missing, rather than a
+ separate preview sound.
+- **Ignored while ARMED or FLAMEOUT**, so a test can never mask or imitate a
+ real alarm. Arming during a test stops it, so a forgotten switch can't
+ sound in flight.
+- **Works with monitoring off**, so the sound can be checked before setup
+ is finished. This is the one exception to "silent when OFF" (FR-012,
+ SC-009).
+
+The test never changes `state`: it's a separate `testOn` flag, so the state
+machine (R8) is untouched. The telemetry-lost variant isn't part of the
+test.
+
+**Alternatives:** an F-key "Test" in the settings form (works only while
+the form is open and can't be bound to a transmitter switch; could be added
+later); one cycle per press (a held switch couldn't check the 5-second
+rhythm).
diff --git a/specs/003-flameout-alarm/spec.md b/specs/003-flameout-alarm/spec.md
new file mode 100644
index 0000000..643be32
--- /dev/null
+++ b/specs/003-flameout-alarm/spec.md
@@ -0,0 +1,831 @@
+# Feature Specification: Flameout Alarm
+
+**Feature Branch**: `003-flameout-alarm`
+
+**Created**: 2026-10-04
+
+**Status**: Draft
+
+**Input**: User description: "Flameout Alarm, an in-flight telemetry app that
+warns the pilot by sound and on screen when the turbine appears to have flamed
+out" (source: `docs/drafts/flameout-alarm.md`, 2026-10-02, alarm sound
+approved by Aaron the same day).
+
+## Overview
+
+**Flameout Alarm** is a transmitter app that watches a turbine's RPM telemetry
+and raises an immediate, unmistakable alarm when the engine spools down while
+the pilot expects it to be running.
+
+**Problem.** A turbine flameout in flight is easy to miss. The model keeps
+flying, the engine noise is already distant, and the pilot may not notice the
+loss of thrust until the model is slow, low or out of glide range. Many ECUs
+send RPM over telemetry, so the transmitter can tell the moment the engine
+spools down.
+
+**Why an app and not a native alarm.** The transmitter's own telemetry alarms
+can fire on "RPM below X", optionally gated by a switch, but they can't tell
+"the engine has not started yet" from "the engine was running and stopped".
+A native low-RPM alarm therefore also sounds through the whole start sequence
+and after every shutdown. This app adds **arming**: it alarms only after the
+engine has demonstrably been running at idle or above.
+
+The app only listens and informs. It never controls the model or changes any
+transmitter setting (constitution principle I), and it does not replace the
+ECU's own failsafe, shutdown or auto-restart logic.
+
+Proposed script name: `AG-FlmOt.lua`, menu name "Flameout Alarm". The
+filename becomes permanent at first release (constitution V).
+
+### Terms used in this spec
+
+- **Idle RPM**: the idle speed specified in the engine's ECU setup, typed in
+ by the pilot. The engine settles to it after a start. Both thresholds
+ below are set as percentages of it.
+- **Start overshoot**: at the end of a start or relight, RPM typically rises
+ above idle RPM and then decays slowly back down to it. By then the engine
+ is running on its own.
+- **Arming threshold**: an RPM somewhat below idle RPM (default 90% of it).
+ RPM at or above it counts as "engine running". It sits below the settled
+ idle so normal idle wander doesn't keep crossing it, and above any RPM the
+ engine reaches before it is running on its own.
+- **Startup range**: RPM below the arming threshold, reached while the
+ starter and ignition spin the engine during a start or an ECU auto-restart.
+- **Flameout threshold**: an RPM below the arming threshold (default 70% of
+ idle RPM). Falling below it while armed is treated as a flameout.
+- **Arming time**: how long RPM must stay at or above the arming threshold
+ before the app arms, or before an active flameout clears after a relight.
+
+In order, from low to high: startup range < flameout threshold < arming
+threshold < idle RPM < start overshoot.
+- **Detection delay**: how long RPM must stay below the flameout threshold
+ before the alarm starts.
+- **Telemetry-loss delay**: how long the RPM sensor must go without valid
+ data before the app treats telemetry as lost. Shorter dropouts are
+ ignored.
+- **Cut switch**: the throttle safety switch and its Cut (engine off)
+ position. Selected in this app independently of any other app.
+- **Alarm cycle**: one 5-second repetition of the alarm sound: the voice
+ callout "Flameout! Flameout! Flameout!" followed by the lock tone.
+
+## Clarifications
+
+### Session 2026-10-04
+
+- Q: Is a Cut switch required before monitoring can be turned on? → A: Yes.
+ Monitoring needs both an RPM sensor and a Cut switch. Without one, every
+ normal shutdown of an armed engine would sound the alarm (FR-003, FR-005).
+- Q: How does the pilot silence an active alarm? → A: With the Cut switch
+ only. There is no separate acknowledge action; the draft's "acknowledged
+ Flameout" state is dropped. Cut stops the alarm and disarms (FR-016,
+ FR-019). Context: the transmitter passes key presses to an app only while
+ its settings screen is open, so a key on the main screen was not an option.
+ Moving to Cut also commands the ECU to shut down, so it ends any
+ auto-restart attempt; a pilot who wants the auto-restart to finish hears the
+ alarm until the engine relights.
+- Q: Turbine starts overshoot idle and then decay slowly back to the ECU's
+ idle (raised by the user). How should idle be determined and used? → A:
+ For this release, idle RPM is **typed in only**: the pilot enters the idle
+ specified in the ECU setup. The draft's "learn idle" helper is dropped,
+ since learning during the decay would record too high a value. Arming and
+ relight use a separate **arming threshold** set as a percentage below idle
+ (default 90%), so the settled idle and its normal wander sit safely above
+ it, while the overshoot simply counts as running (FR-006, FR-007). This
+ replaces the earlier assumption of no idle tolerance in v1.
+- Q: What does the app show on screen? → A: A double-size telemetry window
+ showing RPM with a marker at idle RPM and the RPM as a number, in Speed
+ Gauge's color scheme. The earlier plan to list the threshold values in the
+ window is dropped: they don't fit at 150 × 68 and are already shown in
+ settings.
+- Q: What should the double-size layout look like? → A: Like a motorsport
+ dash tachometer (the user supplied a Haltech iC-7 dash layout as the
+ reference): a segmented sweep bar that curves upward and grows toward the
+ high end, with the large RPM number under it. A plain straight bar with
+ the number above it is the fallback if the sweep doesn't work on the
+ transmitter, and is the layout for single size (User Story 7,
+ FR-031–FR-031c).
+- Q: If telemetry is lost while the flameout alarm is already sounding,
+ what should the alarm do? → A: Keep going; the window shows FLAMEOUT with
+ a "no telemetry" note. It still stops only on Cut or a relight seen in
+ valid data (FR-020a). Revised the same day: once the loss passes the
+ telemetry-loss delay, the "telemetry lost" callout is spoken once between
+ alarm cycles, so the pilot also knows the data is gone.
+- Q: How long must the RPM sensor go without valid data before the app
+ treats it as telemetry lost? → A: Adjustable in settings as the
+ telemetry-loss delay, default 2 s (FR-008, FR-020).
+- Q: How can the alarm be heard during development, since the emulator
+ plays no app audio? → A: An optional test alarm switch or button
+ (FR-030a, User Story 8). While it's on, the real alarm plays; it works
+ with monitoring off, but is ignored while Armed or in Flameout.
+- Q: How are idle and max RPM entered, given the transmitter's number entry
+ stops at 32,767? → A: To the nearest 100 RPM, shown in thousands with one
+ decimal ("35.0" = 35,000 RPM) (FR-006).
+- Q: How should the app handle ECUs that send RPM in a scaled unit, such as
+ thousands of RPM? → A: Plain RPM by default, with a manual sensor scale
+ setting (×1, ×10, ×100, ×1000, default ×1). No automatic unit conversion
+ (FR-006a, FR-010).
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - Hear a flameout the moment it happens (Priority: P1)
+
+A pilot flying a turbine model has the app enabled and configured. In flight,
+the engine flames out. Within the detection delay the transmitter starts the
+alarm: "Flameout! Flameout! Flameout!" in the app voice, then a rapid
+missile-lock beeping, repeating every 5 seconds. The app's telemetry window
+shows a large "FLAMEOUT" and the stick vibrates where the transmitter supports
+it. The alarm continues until the engine relights and holds at or above the
+arming threshold, or the
+pilot moves the Cut switch to Cut.
+
+**Why this priority**: This is the reason the app exists. Everything else
+supports a timely, correct alarm.
+
+**Independent Test**: In the emulator, with a simulated RPM sensor and the
+app enabled, raise RPM above the arming threshold for longer than the arming
+time, then drop
+it to zero. The alarm must start within (detection delay + 0.5 s) and repeat
+its 5-second cycle until stopped.
+
+**Acceptance Scenarios**:
+
+1. **Given** the app is Armed and the Cut switch is not in Cut, **When** RPM
+ stays below the flameout threshold for the detection delay, **Then** the
+ alarm starts, the telemetry window shows FLAMEOUT, and the stick vibrates
+ if supported.
+2. **Given** the alarm is active, **When** 5 seconds pass from the start of a
+ callout, **Then** the next callout starts, with the lock tone filling the
+ time between callouts.
+3. **Given** the app is Armed, **When** RPM dips below the flameout threshold
+ for less than the detection delay (a glitch or single bad sample), **Then**
+ no alarm sounds.
+4. **Given** another app is speaking when the alarm starts or a callout is
+ due, **Then** the alarm interrupts or pre-empts it rather than waiting
+ behind it.
+
+---
+
+### User Story 2 - Silence during normal operation (Priority: P1)
+
+A pilot powers on, starts the turbine, idles, taxis, flies with run-ups and
+throttle chops to idle, lands, and shuts down with the Cut switch. The app
+makes no flameout alarm at any point. It arms quietly once RPM has held at or
+above the arming threshold for the arming time, typically during the start
+overshoot or at the settled idle (with an optional short "armed"
+confirmation), and disarms silently when the Cut switch goes to Cut.
+
+**Why this priority**: An alarm that cries wolf gets ignored or turned off.
+False alarms make the app worse than no app, so this is as important as the
+alarm itself.
+
+**Independent Test**: Replay or simulate a full normal flight profile
+(power-on, start sequence with RPM rising through the startup range,
+overshooting idle and decaying back to it, idle, run-ups, chops to idle,
+landing, Cut). Zero flameout alarms.
+
+**Acceptance Scenarios**:
+
+1. **Given** the model has just loaded with monitoring on, **When** RPM is
+ zero, invalid or anywhere in the startup range, **Then** the app stays
+ Disarmed and silent.
+2. **Given** the app is Disarmed and the Cut switch is not in Cut, **When**
+ RPM holds at or above the arming threshold for the arming time, **Then**
+ the app becomes Armed (with the optional "armed" confirmation if enabled).
+3. **Given** the start overshoots idle and RPM then decays slowly to the
+ settled idle, **Then** RPM never falls below the arming threshold, the app
+ arms and stays Armed, and no alarm sounds.
+4. **Given** the app is Armed, **When** the pilot chops throttle from full to
+ idle, **Then** RPM stays above the flameout threshold and no alarm sounds.
+5. **Given** the app is Armed, **When** the pilot moves the Cut switch to Cut,
+ **Then** the app disarms immediately and silently, and the following
+ spool-down never alarms.
+6. **Given** a start fails or is aborted before RPM holds at or above the
+ arming threshold for the arming time, **Then** the app never arms and
+ never alarms.
+
+---
+
+### User Story 3 - Per-model setup with an assigned RPM sensor (Priority: P1)
+
+Not every turbine sends RPM telemetry. The pilot opens the app's settings on a
+turbine model whose ECU sends RPM, picks that sensor from the model's
+telemetry sensors, selects the Cut switch and its Cut position, types in the
+idle RPM (and, optionally, the max RPM for the bar display) from the ECU
+setup, checks the arming and flameout thresholds
+(percentages of idle), arming time and detection delay, and turns monitoring
+on. Each model keeps its
+own settings; on every other model the app stays off.
+
+**Why this priority**: The alarm cannot work without a correct sensor and
+thresholds, and a model without RPM telemetry must never alarm.
+
+**Independent Test**: On a fresh model, open settings: monitoring is off and
+cannot be turned on until a sensor and a Cut switch are assigned. Assign both
+and enable.
+Switch to a second model: monitoring is off there. Restart the transmitter:
+both models keep their settings.
+
+**Acceptance Scenarios**:
+
+1. **Given** a model where the app has never been configured, **Then**
+ monitoring is off and the telemetry window shows OFF.
+2. **Given** no RPM sensor or no Cut switch is assigned, **When** the pilot
+ tries to turn monitoring on, **Then** it stays off and the settings screen
+ says which one must be assigned first.
+3. **Given** the model's sensor list is empty, **When** the pilot opens
+ settings, **Then** the screen explains that the turbine's ECU must send RPM
+ telemetry and that telemetry must have been received at least once
+ (transmitter and receiver powered on).
+4. **Given** the pilot types in idle RPM, **Then** settings show the arming
+ and flameout thresholds in RPM, computed from their percentages, next to
+ the percentages.
+5. **Given** the pilot sets a flameout threshold at or above the arming
+ threshold, or an arming threshold at or above idle RPM, **Then** settings
+ refuse the value and explain why.
+6. **Given** monitoring is enabled on model A, **When** the pilot loads model
+ B, **Then** monitoring is off on model B unless it was enabled there.
+
+---
+
+### User Story 4 - Auto-restart and relight (Priority: P2)
+
+The engine flames out and the ECU tries an auto-restart. While the starter and
+ignition push RPM up through the startup range, the alarm keeps going. If the
+engine relights and RPM holds at or above the arming threshold for the arming
+time (usually while it overshoots idle), the alarm clears, the app returns to
+Armed, and it can announce "relit" (optional). As RPM then decays to the
+settled idle it stays above the arming threshold, so the app stays Armed. If the restart fails and RPM falls back, the original flameout
+stays active and no second alarm event starts.
+
+**Why this priority**: Without this, an auto-restart attempt could falsely
+clear the alarm while the engine is still not producing thrust, or produce a
+confusing stream of new alarms.
+
+**Independent Test**: Simulate a flameout, then an RPM profile that climbs
+through the startup range, falls back, climbs again, overshoots idle and
+decays to it. The alarm must not clear in the startup range, must clear
+within (arming time + 0.5 s) of RPM holding at or above the arming
+threshold, must stay clear through the decay, and must never start a second
+event.
+
+**Acceptance Scenarios**:
+
+1. **Given** the alarm is active, **When** RPM rises anywhere within the
+ startup range, **Then** the alarm continues.
+2. **Given** the alarm is active, **When** RPM holds at or above the arming
+ threshold for the arming time, **Then** the alarm stops, the app returns
+ to Armed, and the optional "relit" callout plays.
+3. **Given** the alarm has just cleared on a relight overshoot, **When** RPM
+ decays to the settled idle, **Then** the app stays Armed and silent.
+4. **Given** the alarm is active, **When** RPM wobbles within the startup
+ range and falls back, **Then** the same alarm continues and no second
+ alarm event starts.
+
+---
+
+### User Story 5 - Stop the alarm with the Cut switch (Priority: P2)
+
+The pilot has heard the alarm and is setting up the dead-stick landing. They
+move the Cut switch to Cut, as they would to secure a dead engine. All alarm
+sound stops at once and the app goes to Disarmed. It does not alarm again
+until the engine has been restarted and RPM has held at or above the arming
+threshold for the arming time with the switch out of Cut.
+
+**Why this priority**: A continuous alarm during a dead-stick approach is a
+distraction once the pilot knows. Using the switch the pilot already reaches
+for avoids a second control.
+
+**Independent Test**: Trigger the alarm in the emulator, move the Cut switch
+to Cut: sound stops at once, the window shows DISARMED, and no further
+callouts follow while RPM stays low.
+
+**Acceptance Scenarios**:
+
+1. **Given** the alarm is active, **When** the pilot moves the Cut switch to
+ Cut, **Then** all alarm sound stops at once, mid-callout or mid-beep, and
+ the app is Disarmed.
+2. **Given** the app was disarmed by Cut after a flameout, **When** RPM stays
+ low or moves within the startup range, **Then** no alarm sounds.
+3. **Given** the app was disarmed by Cut, **When** the switch leaves Cut and
+ RPM holds at or above the arming threshold for the arming time, **Then**
+ the app is Armed again and a
+ later flameout alarms normally.
+
+---
+
+### User Story 6 - Tell telemetry loss apart from a flameout (Priority: P2)
+
+While the app is Armed, the RPM sensor stops reporting valid data (receiver
+out of range, ECU converter unplugged). The app gives a distinct "telemetry
+lost" warning, never the flameout alarm, and shows NO TELEMETRY. When valid
+data returns, the app continues from the RPM it then sees.
+
+**Why this priority**: The pilot's response to a dead engine and to lost
+telemetry is different; confusing them is dangerous.
+
+**Independent Test**: With the app Armed in the emulator, stop the simulated
+RPM sensor. The "telemetry lost" warning plays, not the flameout alarm, and
+the window shows NO TELEMETRY.
+
+**Acceptance Scenarios**:
+
+1. **Given** the app is Armed, **When** RPM data stops being valid, **Then**
+ the app gives the "telemetry lost" warning and shows NO TELEMETRY, and the
+ flameout alarm does not sound.
+2. **Given** the model loads and telemetry has never been received, **Then**
+ the window shows NO TELEMETRY and the app never alarms.
+3. **Given** NO TELEMETRY while Armed, **When** valid data returns with RPM at
+ or above the arming threshold, **Then** the app is Armed again with no
+ alarm.
+4. **Given** the flameout alarm is active, **When** RPM data has been
+ invalid for the telemetry-loss delay, **Then** the alarm continues, the
+ "telemetry lost" callout is spoken once between alarm cycles, and the
+ window shows FLAMEOUT with a "no telemetry" note.
+
+---
+
+### User Story 7 - RPM bar display (Priority: P2)
+
+The pilot places the app's telemetry window on the main screen at double
+size. It looks like a motorsport dash tachometer (inspiration: a Haltech
+iC-7 dash layout the user supplied, kept locally as
+`docs/vendor/rpm-bar-reference.png` and not committed because it is a
+third-party image). The working design is the original mockup
+`design/rpm-window-mockup.svg` in this spec folder, generated by
+`design/mockup.py`. The RPM is a **segmented sweep bar**: a row of blocks running
+left to right that curves upward and grows taller toward the high-RPM end.
+Segments light up from the left as RPM rises; unlit segments stay visible as
+dim outlines. A marker on the sweep shows idle RPM. The RPM is a large number
+in the open space under the sweep, labeled "RPM", and the app's state (OFF,
+DISARMED, ARMED, FLAMEOUT, NO TELEMETRY) is shown in the window. The pilot
+can see at a glance that the engine is running, how far above idle it is,
+and that the app is armed before take-off. On a flameout, FLAMEOUT takes
+over the window. Colors follow Speed Gauge (dark face, bright colors), not
+the reference's amber.
+
+At single size the window falls back to a simple straight bar with an idle
+marker beside the RPM number.
+
+**Why this priority**: It's the pilot's everyday view of the engine and
+confirms the app is armed before take-off. The audio alarm still carries the
+safety value, so it ranks below the P1 stories.
+
+**Independent Test**: Place the window at double size in the emulator, drive
+the simulated RPM from 0 through idle to max and back, and step through each
+state. The bar tracks RPM, the idle marker sits at idle RPM, the number
+matches the sensor, and everything fits and reads clearly in the 150 × 68
+transmitter window.
+
+**Acceptance Scenarios**:
+
+1. **Given** valid RPM, **Then** the lit segments of the sweep are in
+ proportion to RPM between zero and the bar's full-scale RPM, and the
+ number shows the current RPM.
+2. **Given** idle RPM is entered, **Then** a marker on the sweep shows where
+ idle RPM sits, and the lit segments reach the marker when RPM equals idle
+ RPM.
+3. **Given** RPM above full scale, **Then** the bar shows full and the number
+ still shows the true RPM.
+4. **Given** each app state, **Then** the window shows the matching state
+ label.
+5. **Given** NO TELEMETRY or OFF, **Then** no segments are lit, the number
+ shows no value (e.g. "---"), and the state label says why.
+6. **Given** the alarm is active, **Then** FLAMEOUT is the dominant element
+ of the window, in the alarm color.
+7. **Given** the window is placed at single size, **Then** it shows a
+ straight bar with the idle marker, the RPM number and the state.
+
+### User Story 8 - Test the alarm from a switch (Priority: P3)
+
+The JETI Studio emulator plays no app audio, so the pilot can only hear the
+alarm on the transmitter. In settings they assign a test alarm switch or
+button. With the model on the bench, they press it and hear the real alarm,
+with the same sounds, rhythm and vibration as a flameout, and see TEST in
+the window. Releasing it stops the sound at once.
+
+**Why this priority**: Lets the pilot confirm volume, voice and rhythm
+before relying on the alarm, and lets development check the sound without
+an engine. The alarm works without it.
+
+**Independent Test**: On the transmitter, assign a momentary button as the
+test switch with monitoring off. Press and hold it for 12 seconds: the
+alarm plays three cycles 5 s apart with vibration; release it: silence at
+once.
+
+**Acceptance Scenarios**:
+
+1. **Given** the app is OFF or DISARMED, **When** the test switch turns on,
+ **Then** the alarm starts at once and repeats every 5 s while the switch
+ stays on, and the window shows TEST.
+2. **Given** a test alarm is playing, **When** the test switch turns off,
+ **Then** all alarm sound stops at once.
+3. **Given** the app is Armed or in Flameout, **When** the test switch turns
+ on, **Then** nothing changes.
+4. **Given** the alarm audio files are missing, **When** the test switch
+ turns on, **Then** the fallback beeps play, as in a real alarm.
+
+### Edge Cases
+
+- **Test switch left on.** A test alarm keeps cycling while the app is OFF
+ or DISARMED. Once the app arms, the test stops and is ignored, so a
+ forgotten test switch can't sound in flight. To test again, turn the
+ switch off and on.
+- **Test switch on at power-up or model load.** It counts only when it
+ changes from off to on, so a switch left on doesn't start an alarm at
+ model load.
+
+- **Turbine has no RPM sensor.** The user leaves monitoring off; the app stays
+ silent and shows OFF. Adding the app to such a model never causes alarms.
+- **User tries to enable with no sensor assigned.** Blocked, with an
+ explanation.
+- **No telemetry sensors listed at setup** (receiver off, or ECU not sending).
+ Setup explains that telemetry must be received once before a sensor can be
+ assigned.
+- **Throttle chop from full to idle.** RPM falls fast but stays above the
+ flameout threshold. No alarm. This drives how far below idle the default
+ threshold sits.
+- **Brief telemetry dropout or a single bad sample.** A bad RPM sample is
+ ridden out by the detection delay, and a dropout shorter than the
+ telemetry-loss delay by that delay; no alarm and no "telemetry lost"
+ callout.
+- **Sensor reports 0 or invalid at power-on.** Stays Disarmed.
+- **Failed start or hot-start abort.** RPM never holds at or above the
+ arming threshold for the arming time, so no arming and no alarm.
+- **Start overshoot and slow decay to idle.** RPM rises above idle RPM, then
+ decays back to it over several seconds. The app arms during the overshoot
+ or at the settled idle, and the decay never crosses the arming threshold,
+ so it stays Armed. No alarm.
+- **ECU auto-restart after flameout.** The alarm keeps going until RPM holds
+ at or above the arming threshold for the arming time (usually during the
+ relight overshoot), then clears and the app re-arms. The decay to settled
+ idle that follows does not disturb it.
+- **Auto-restart fails partway.** RPM rises into the startup range and falls
+ back. No clear, no second alarm; the original flameout stays active.
+- **Startup RPM peaks close to the arming threshold.** If the engine reaches
+ the arming threshold before it is running on its own, a failed restart
+ could clear the alarm early. The README explains the rule, and the arming
+ percentage can be raised for such an engine.
+- **Idle RPM typed in wrong.**
+ - Too high (e.g. the pilot entered a run-up RPM): the settled idle sits
+ below the arming threshold. The app arms only on a run-up, and after a
+ relight the alarm keeps sounding at idle until the pilot opens the
+ throttle.
+ - Too low: the thresholds sit lower than intended, so the alarm comes
+ later in a spool-down and a restart can clear it sooner.
+ - Settings show the live RPM next to the typed idle so the pilot can
+ compare them with the engine idling, and the README explains both cases.
+- **Settled idle wanders** (temperature, altitude, fuel). The arming
+ threshold sits a margin below idle RPM (default 10%), so normal wander
+ doesn't cross it.
+- **Relight or Cut mid-callout or mid-beep.** All alarm sound
+ stops at once; no further callouts.
+- **Another app (e.g. Speed Gauge) is speaking when the alarm fires or a
+ callout is due.** The alarm interrupts or pre-empts it.
+- **Alarm audio file missing** (for example, the voice file was not
+ generated). The app falls back to the transmitter's built-in beeps plus the
+ on-screen FLAMEOUT, and settings shows "voice files missing". A missing file
+ never silences the alarm.
+- **Pilot flips to Cut during the alarm.** The alarm stops; the state becomes
+ Disarmed. This is the only way to silence the alarm without a relight.
+- **Pilot wants the ECU auto-restart to finish.** Cut would end the restart,
+ so the alarm keeps sounding until the engine relights and holds at or above
+ the arming threshold, or the pilot gives up and moves to Cut.
+- **Cut switch unassigned or missing** (e.g. a model copied from one with a
+ different switch layout). Monitoring cannot be enabled without it; if it
+ disappears after enabling, the app treats monitoring as off and settings say
+ why.
+- **Wrong sensor selected** (e.g. EGT instead of RPM). Setup warns if the
+ sensor's unit isn't plain RPM, or if its value never changes.
+- **ECU sends scaled RPM** (e.g. 112 meaning 112,000). With the default ×1
+ scale the live RPM in settings reads far below the typed idle, and the
+ unit warning shows; the pilot sets the sensor scale to match (×1000 here).
+ Until then the app never arms, so it never false-alarms.
+- **Assigned sensor disappears** after an ECU swap, receiver rebind or model
+ copy. The app shows "sensor not found" and stays Disarmed instead of
+ erroring; the user can re-assign it or turn monitoring off.
+- **Model copied from another model.** The copied sensor assignment may not
+ exist on the new model's receiver; handled as above.
+- **Telemetry lost during the flameout alarm.** The engine is most likely
+ still out, so the alarm keeps going; once the loss passes the
+ telemetry-loss delay, "telemetry lost" is spoken once between alarm
+ cycles. Only Cut or a relight seen in valid data stops the alarm.
+- **Thresholds set nonsensically** (flameout threshold at or above the
+ arming threshold, or arming threshold at or above idle RPM). Setup blocks
+ it and explains.
+- **RPM above the bar's full scale** (max RPM entered too low, or not
+ entered and the default multiple is too small). The bar shows full; the
+ number still shows the true RPM.
+- **Max RPM entered at or below idle RPM.** Settings refuse it and explain.
+- **Idle RPM never entered.** There is no sensible default (idle differs
+ widely between engines), so monitoring can't be turned on until it is.
+- **Two engines.** Out of scope for v1 (see Assumptions).
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+**Configuration and per-model state**
+
+- **FR-001**: Monitoring MUST be off by default on every model.
+- **FR-002**: Users MUST be able to assign the RPM sensor from the model's
+ telemetry sensors.
+- **FR-003**: Monitoring MUST NOT be able to be turned on until an RPM
+ sensor and a Cut switch are assigned and idle RPM is entered. The settings
+ screen MUST say which is missing when the user tries.
+- **FR-004**: When the model has no telemetry sensors listed, the settings
+ screen MUST explain that the ECU must send RPM telemetry and that telemetry
+ must have been received at least once, and that some ECU adapters take up
+ to a minute after power-up before their sensors appear.
+- **FR-005**: Users MUST be able to select the Cut switch and its Cut
+ position. A Cut switch is required for monitoring (FR-003).
+- **FR-006**: Users MUST set idle RPM by typing in the idle specified in the
+ ECU setup. It has no default. There is no automatic or learned idle in this
+ release. Idle RPM and max RPM (FR-031b) are entered to the nearest 100 RPM
+ and shown in thousands with one decimal, e.g. "35.0" for 35,000 RPM and
+ "152.0" for 152,000 RPM, because the transmitter's number entry can't hold
+ values above 32,767 (research R1).
+- **FR-006a**: Users MUST be able to set a sensor scale of ×1, ×10, ×100 or
+ ×1000, default ×1 (the sensor sends plain RPM). The app multiplies the
+ sensor's value by the scale before every use: display, arming and
+ detection. Idle RPM and max RPM are always entered as true engine RPM
+ (FR-006), whatever the sensor scale. The app does not convert units
+ automatically.
+- **FR-007**: Users MUST be able to set the arming threshold and the
+ flameout threshold as editable percentages of idle RPM, defaulting to 90%
+ and 70% (see Assumptions). Settings MUST show each one's RPM value next to
+ its percentage. Settings MUST refuse an arming threshold at or above idle
+ RPM, and a flameout threshold at or above the arming threshold.
+- **FR-008**: Users MUST be able to set the arming time (default 3 s), the
+ detection delay (default 1.0 s) and the telemetry-loss delay (default
+ 2 s).
+- **FR-009**: Users MUST be able to turn the optional "armed" and "relit"
+ confirmations on or off (both default off).
+- **FR-010**: Setup MUST warn when the assigned sensor's unit isn't plain RPM
+ (pointing the pilot to the sensor scale), and MUST show the live RPM, after
+ scaling, next to the typed idle RPM so the pilot can check both against the
+ idling engine.
+- **FR-011**: All settings MUST be saved per model and survive a transmitter
+ restart. Enabling the app on one model MUST NOT enable it on another.
+
+**Off state**
+
+- **FR-012**: When monitoring is off, or the RPM sensor, Cut switch or idle
+ RPM is missing, the app MUST produce no sounds (other than a test alarm
+ the pilot triggers, FR-030a), vibration or warnings in any scenario, including
+ telemetry loss, and its telemetry window MUST show OFF.
+
+**Arming and detection**
+
+- **FR-013**: When monitoring is on, the app MUST start Disarmed at model
+ load, and RPM that is zero, invalid or within the startup range MUST keep
+ it Disarmed.
+- **FR-014**: The app MUST arm only when the Cut switch is not in Cut and RPM
+ has stayed at or above the arming threshold for the arming time. RPM above
+ idle RPM (the start overshoot) counts toward arming like any RPM at or
+ above the arming threshold.
+- **FR-015**: While Armed, the app MUST raise the flameout alarm when RPM has
+ stayed below the flameout threshold for the detection delay and the Cut
+ switch is not in Cut.
+- **FR-016**: Moving the Cut switch to Cut MUST disarm the app immediately and
+ silently from any state, stopping any alarm sound at once.
+- **FR-017**: While in Flameout, RPM within the startup range MUST NOT clear
+ the flameout and MUST NOT start a new alarm event.
+- **FR-018**: A flameout MUST clear only when RPM has stayed at or above the
+ arming threshold for the arming time (or when the Cut switch goes to Cut). On clearing by
+ relight the app MUST return to Armed and play the "relit" confirmation if
+ enabled.
+- **FR-019**: The Cut switch MUST be the only way for the pilot to silence an
+ active alarm. There is no separate acknowledge action.
+
+**Telemetry loss**
+
+- **FR-020**: When the RPM sensor has reported no valid data for the
+ telemetry-loss delay while Armed, the app MUST give a "telemetry lost" warning that is clearly different from the
+ flameout alarm, and MUST show NO TELEMETRY. Telemetry loss MUST never
+ trigger the flameout alarm.
+- **FR-020a**: When the RPM sensor has reported no valid data for the
+ telemetry-loss delay while the flameout alarm is active, the alarm MUST
+ continue, the "telemetry lost" callout MUST be spoken once between two
+ alarm cycles (without stopping or restarting the alarm), and the window
+ MUST show FLAMEOUT with a "no telemetry" note. The alarm still stops only
+ on Cut, or on a relight seen in valid data (FR-018).
+- **FR-021**: When valid data returns, the app MUST resume from the RPM it
+ then sees: Armed if RPM is at or above the arming threshold, otherwise following the
+ normal detection rules (the detection delay restarts).
+- **FR-022**: If the assigned sensor is not found among the model's sensors,
+ the app MUST show "sensor not found", stay Disarmed and not error.
+
+**Alarm sound**
+
+- **FR-023**: The alarm MUST repeat a 5-second cycle: a voice callout
+ "Flameout! Flameout! Flameout!" (about 2 s), then a rapid, high-pitched
+ lock tone filling the rest of the cycle. A new callout MUST start every
+ 5 s (±0.3 s), callout start to callout start, with no audible gap or
+ overlap between callout and tone.
+- **FR-024**: The alarm MUST continue until cleared by relight or stopped by
+ Cut.
+- **FR-025**: The alarm MUST interrupt or pre-empt speech from other apps
+ rather than queue behind it.
+- **FR-026**: The callout MUST use the same Piper "Amy" voice as Speed Gauge
+ (Speed Gauge spec, User Story 6 and FR-030–FR-037), so all AG- apps sound
+ consistent, but delivered urgently: about 30% faster, pitch raised about one
+ semitone without changing speed, with presence boost and light compression
+ so it cuts through wind and engine noise. The approved processing settings
+ are recorded in `docs/drafts/flameout-alarm.md`.
+- **FR-027**: The lock tone MUST match the approved sample: about 1800 Hz with
+ a 3600 Hz overtone, 45 ms on / 35 ms off with short fades (12.5 beeps per
+ second).
+- **FR-028**: Both alarm sounds MUST be audio files in the app's own asset
+ folder (`AG-FlmOt/`), not references to another app's files. The voice
+ files MUST follow Speed Gauge's current voice rules (Speed Gauge FR-034 to
+ FR-036): generated by a script in `tools/voice/` from the same Amy model,
+ committed to the repository under CC BY-SA 4.0 (not MIT), and credited for
+ Piper and the Amy model in the folder's `CREDITS.txt`, in `CREDITS.md` and
+ in the README. The lock tone is plain synthesized sound with no voice
+ license. (Corrected 2026-10-04: the draft said "not committed", which was
+ Speed Gauge's rule before 2026-10-03.)
+- **FR-029**: If an alarm audio file is missing, the app MUST fall back to the
+ transmitter's built-in beeps with the on-screen FLAMEOUT, and the settings
+ screen MUST show "voice files missing". A missing file MUST never silence
+ the alarm.
+- **FR-030**: While the alarm is active, the app MUST vibrate the stick where
+ the transmitter supports it.
+- **FR-030a**: Users MUST be able to assign an optional **test alarm**
+ switch or button. While it is on, the app MUST play the real alarm (the
+ same cycle sounds, timing, fallback and vibration) and show TEST in its
+ window; turning it off MUST stop the sound at once. The test works
+ whether or not monitoring is on, but MUST be ignored while the app is
+ Armed or in Flameout, so a test can never be mistaken for, or hide, a
+ real alarm.
+
+**Display**
+
+- **FR-031**: The app MUST provide a telemetry window designed for double
+ size (150 × 68 on the transmitter, title drawn above the window). It MUST
+ show:
+ - the RPM as a segmented sweep bar in the style of the mockup
+ (User Story 7): segments run left to right from zero to the full-scale
+ RPM (FR-031b), the sweep curves upward and grows taller toward the high
+ end, lit segments show the current RPM and unlit ones stay visible as
+ dim outlines;
+ - a marker on the sweep at idle RPM;
+ - the current RPM as a large number in the space under the sweep, labeled
+ "RPM";
+ - the state: OFF, DISARMED, ARMED, FLAMEOUT or NO TELEMETRY, or TEST
+ while a test alarm plays (FR-030a).
+ FLAMEOUT MUST be the dominant element while the alarm is active. If the
+ sweep proves unreadable or too costly to draw on the transmitter, the
+ layout MAY fall back to the straight-bar layout of FR-031c scaled up to
+ double size, with the number above the bar.
+- **FR-031a**: The window MUST follow Speed Gauge's look (Speed Gauge
+ research R8): an always-dark face, white numbers, light grey labels, cyan
+ lit segments, dim grey unlit segment outlines and a yellow idle marker by
+ default. Red or orange is used only
+ for the flameout alarm, so nothing else on the display can be mistaken for
+ it.
+- **FR-031b**: Users MUST be able to enter the engine's maximum RPM from the
+ ECU setup as the bar's full-scale RPM. If it isn't entered, the bar scales
+ to a default multiple of idle RPM (see Assumptions). Max RPM affects only
+ the display, never arming or detection, and MUST be above idle RPM.
+- **FR-031c**: If the window is placed at single size (150 × 23), it MUST
+ show a straight horizontal bar filling left to right with the idle marker,
+ the RPM number beside it, and the state. Full screen is out of scope.
+
+**Safety and footprint**
+
+- **FR-032**: The app MUST NOT drive any output or change any transmitter or
+ model setting. It only reads telemetry and switches, plays sound, vibrates
+ and draws (constitution I).
+- **FR-033**: The app MUST install standalone: its script and its own asset
+ folder, plus any shared lib modules, which the plan, install instructions
+ and any release package MUST list. It MUST NOT depend on any other app.
+- **FR-034**: When monitoring is off, the app's per-cycle work MUST be
+ negligible.
+- **FR-035**: The README MUST state that the app is advisory, does not replace
+ the ECU's failsafe, shutdown or auto-restart logic nor the pilot's own
+ monitoring, and only works on turbines whose RPM reaches the transmitter
+ as a normal telemetry sensor. It MUST list what is known per ECU brand
+ (research R1), including that RPM visible only in a JetiBox screen or
+ inside another vendor's app can't be used. It MUST also explain: entering idle RPM from the ECU setup; the start overshoot and
+ why the arming threshold sits below idle; the arming threshold having to
+ stay above any RPM the engine reaches before it runs on its own; and the
+ effects of an idle typed too high or too low.
+
+### Key Entities
+
+- **Model configuration** (one per model): monitoring enabled, assigned RPM
+ sensor and its scale, Cut switch and position, idle RPM (typed in), max RPM
+ for the bar
+ display (optional), arming threshold and
+ flameout threshold (as percentages of idle RPM), arming time, detection
+ delay, telemetry-loss delay, confirmation toggles, test alarm switch
+ (optional).
+- **Engine monitor state** (in memory, reset on model load): one of Off,
+ Disarmed, Armed, Flameout, No telemetry;
+ plus the timers for arming, detection and the alarm cycle.
+- **Alarm assets**: the urgent voice callout and the lock tone, in the app's
+ own folder, with their credits.
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**: In replayed or emulated flameout profiles, the alarm starts
+ within (detection delay + 0.5 s) of RPM crossing below the threshold in
+ 100% of runs.
+- **SC-002**: Zero alarms across a full normal flight profile: power-on,
+ start sequence (including the overshoot above idle and the slow decay back
+ to it), idle, run-ups, throttle chops to idle, landing, and shutdown
+ via the Cut switch.
+- **SC-003**: Zero flameout alarms before the app first arms (start
+ sequence, failed starts, cool-down).
+- **SC-004**: Telemetry loss produces the "telemetry lost" warning, never the
+ flameout alarm, in 100% of tests.
+- **SC-005**: A pilot can enable the app for a model and configure sensor,
+ switch and thresholds in under 3 minutes without any other manual.
+- **SC-006**: The app never changes any transmitter output or setting (servo
+ outputs and model file unchanged after a session with no user edits).
+- **SC-007**: Copying only the app's files (plus any listed lib modules) to
+ `/Apps` on a transmitter with no other AG- apps is enough to load and run.
+- **SC-008**: No "CPU limit" errors over a 30-minute emulator session with the
+ app enabled and alarming at least once.
+- **SC-009**: On a model where monitoring is off (the default), or the RPM
+ sensor, Cut switch or idle RPM is missing, the app produces zero sounds or
+ warnings in any scenario, including telemetry loss, except a test alarm
+ the pilot triggers with the test switch.
+- **SC-010**: Enable state and sensor assignment are kept per model: enabling
+ it on model A does not enable it on model B, and both settings survive a
+ transmitter restart.
+- **SC-011**: In replayed auto-restart profiles, the flameout alarm never
+ clears while RPM is in the startup range, clears within (arming time +
+ 0.5 s) of RPM holding at or above the arming threshold, stays clear while
+ RPM decays from the relight overshoot to idle, and fires no second alarm
+ for the same flameout, in 100% of runs. A failed restart leaves the alarm
+ active.
+- **SC-012**: While the alarm is active, a callout starts every 5 s (±0.3 s),
+ the lock tone plays between callouts with no audible gap or overlap, and the
+ cycle continues until relight or Cut, in 100% of tests.
+- **SC-013**: At double size on the transmitter, a pilot can read the RPM
+ number, the bar's position relative to the idle marker, and the state at a
+ glance (under 1 second) with nothing clipped. The display reflects an RPM
+ change within 1 second.
+
+## Assumptions
+
+- **Script name** `AG-FlmOt.lua` ("Flameout Alarm") is used unless changed
+ before first release.
+- **Default thresholds** are placeholders until real RPM logs are reviewed
+ (draft open question 4): arming threshold 90% of idle RPM, flameout
+ threshold 70% of idle RPM, arming time 3 s, detection delay 1.0 s. Settled
+ idle is assumed to wander by less than 10%, and a throttle chop is assumed
+ to settle at idle without dipping far below it, so 70% leaves margin.
+- **The startup range stays clearly below 90% of idle** on the user's ECUs
+ during starts and auto-restarts, i.e. the engine is running on its own
+ before it reaches the arming threshold (draft open question 2, to confirm
+ from logs).
+- **Start overshoot** decays to idle without dipping below the arming
+ threshold. Logs should confirm the overshoot height, decay time and how
+ much settled idle wanders; they set the 90% default.
+- **Idle RPM is typed in only** in this release, from the ECU setup. A
+ "learn idle" helper or an idle derived automatically each session is a
+ possible later extension; either would need to wait for the overshoot to
+ decay before recording.
+- **ECU telemetry** (research R1): JetCat, Xicoy, KingTech and Swiwin (via
+ a VSpeak converter) deliver RPM as a plain RPM value on a standard
+ telemetry sensor, so the default ×1 scale fits. JetCentral's adapter V2,
+ Enjet, and Swiwin's direct connection are unconfirmed and are checked in
+ testing. ECU status sensors exist for some brands but not all, so v1 uses
+ RPM only.
+- **Bar full scale** defaults to 4 × idle RPM when max RPM isn't entered.
+ Typical model turbines reach roughly 3–4.5 times their idle speed at full
+ throttle, so
+ this puts full throttle near the right end of the bar.
+- **Display layout** in FR-031 is a starting point for design iteration in
+ the plan: the number of segments (the reference has about 16; around 10–14
+ is likely at 150 × 68), the curve and taper of the sweep, where the state
+ label and idle marker go, and whether threshold marks join the idle
+ marker. It is checked on the transmitter's 150 × 68 window, not the
+ emulator's larger one. Drawing cost is checked too (constitution VI): the
+ sweep is built from plain filled shapes without transparency, which Speed
+ Gauge's measurements show are cheap, or from a pre-drawn image of the
+ unlit sweep.
+- **Detection uses a fixed RPM threshold only** in v1. Rate-of-drop
+ detection and ECU status sensors are possible later extensions (draft open
+ questions 3 and 5).
+- **"Telemetry lost" warning** is a short spoken "Engine telemetry lost" in
+ the app voice, given once per loss event (once the telemetry-loss delay
+ has passed, whether Armed or alarming), with NO TELEMETRY shown until data
+ returns. It does not repeat, because the transmitter already announces
+ signal loss itself.
+- **One engine per model** in v1. Two-engine models are out of scope; a later
+ version could monitor one RPM sensor per engine.
+- **Target hardware** is the DS-24 II / DC-24 II on firmware 6.x. The
+ emulator plays no Lua audio, so the alarm sound and its timing can only be
+ verified on the transmitter.
+- **Turbine Throttle Setup** (spec 002, separate app) uses the same physical
+ safety switch, but this app selects it independently and does not read the
+ other app's settings.
+- API availability (sensor validity, audio playback priority and stopping,
+ vibration, sensor identity across power cycles) is confirmed during
+ planning; the draft's open questions 1–12 feed `/speckit-clarify` and
+ `/speckit-plan`.
diff --git a/specs/003-flameout-alarm/tasks.md b/specs/003-flameout-alarm/tasks.md
new file mode 100644
index 0000000..43fe37c
--- /dev/null
+++ b/specs/003-flameout-alarm/tasks.md
@@ -0,0 +1,273 @@
+---
+
+description: "Task list for Flameout Alarm (AG-FlmOt)"
+---
+
+# Tasks: Flameout Alarm
+
+**Input**: Design documents from `specs/003-flameout-alarm/`
+
+**Prerequisites**: [plan.md](plan.md), [spec.md](spec.md), [research.md](research.md), [data-model.md](data-model.md), [contracts/](contracts/), [quickstart.md](quickstart.md)
+
+**Tests**: The spec doesn't ask for automated tests. The checks are the
+sound generator's self-check (T001), the `SIM` profiles (T024) and the
+manual scenarios in [quickstart.md](quickstart.md).
+
+**Organization**: Tasks are grouped by user story so each story can be
+built and checked on its own.
+
+## Format: `[ID] [P?] [Story] Description`
+
+- **[P]**: Can run in parallel (different files, no dependencies)
+- **[Story]**: Which user story this task belongs to (US1–US8)
+- **MANUAL**: Needs a person at JETI Studio or the transmitter. An agent
+ can't run the emulator (CLAUDE.md): it prepares the build, lists what to
+ check, and leaves the box unticked until the user reports back.
+
+## Rules for every code task
+
+- Follow `docs/examples/style/HELLO.lua`: 2-space indent, every variable and
+ function `local` (constitution II), sections in the order given in
+ [plan.md](plan.md#source-code-repository-root). Group constants, colors
+ and geometry in tables: the main chunk may hold at most 200 locals.
+- Call only APIs declared in `types/jeti.lua` (constitution III). Never call
+ `system.registerControl`, `system.setControl` or `system.setProperty`.
+ No `os`, `debug`, `coroutine`, `bit32`.
+- Call `system.getSensor*` through `system` every time; never keep a local
+ copy of those functions (the emulator's telemetry app replaces them,
+ research R9).
+- `lcd.*` only in the print function; `form.*` only in the form's init,
+ callbacks and close function. Exception: `loop()` may update form rows
+ only while `formOpen` is true (research R10).
+- No `string.format`, `..` or `{}` in `loop()` or the print function, except
+ rebuilding `rpmText` when RPM / 100 changes (data-model.md).
+- `src/Apps/AG-FlmOt.lua` is one file, so tasks that touch it run one after
+ another. Generator, docs and emulator config tasks are separate files.
+- Run `python tools/check.py` after each phase. It must report 0 errors.
+
+---
+
+## Phase 1: Setup (Shared Infrastructure)
+
+**Purpose**: The alarm sounds, the emulator RPM sensor and the asset folder
+
+- [X] T001 [P] Create `tools/voice/make_flameout.py` (Python 3.9, MIT header like `tools/voice/make_voice.py`) per research R5 and `specs/003-flameout-alarm/contracts/audio-events.md`. Import `synthesize`, `trim`, `normalize`, `resample`, `write_wav`, `write_text` and `PEAK` from `make_voice.py` (add `tools/voice` to `sys.path`; do not modify `make_voice.py`). Arguments: `--model` (required, path to `en_US-amy-medium.onnx`), `--out` (default `src/Apps/AG-FlmOt`), `--rate` (default 22050). Urgent phrase "Flameout!": Piper `SynthesisConfig(length_scale=0.749, noise_scale=0.5)`, then `resample(samples, round(src_rate * 1.07), src_rate)` (treat the audio as 7% faster and convert back, so it plays 7% faster and about one semitone higher: the `asetrate` step, R5; the final resample to `--rate` follows as in `make_voice.py`), then a 150 Hz high-pass biquad, a +4 dB peaking biquad at 3 kHz (Q 1.0) (RBJ cookbook formulas), a compressor (threshold −18 dBFS, ratio 4, attack 3 ms, release 60 ms, makeup +4 dB, peak envelope follower), `trim`, `normalize`. Build the callout as three copies joined by 0.12 s of silence. Calm phrases at Speed Gauge's speed (`length_scale = 1/1.5`, no effects, then `trim`, `normalize`): "Engine telemetry lost", "Flameout alarm armed", "Engine relit". Lock tone generator: 1800 Hz sine plus 3600 Hz overtone at 0.3 relative amplitude, 45 ms on / 35 ms off, 3 ms linear fades, normalized to `PEAK`. Write `cycle.wav` = callout + lock tone to exactly 5.000 s (110,250 samples at 22,050 Hz; scale with `--rate`); `cycletl.wav` = callout + 0.15 s gap + "Engine telemetry lost" + lock tone to exactly 5.000 s; `tlost.wav`, `armed.wav`, `relit.wav`; and `CREDITS.txt` (adapt `make_voice.py`'s CREDITS text: "Flameout Alarm (AG-FlmOt) - alarm sounds", Piper MIT, Amy CC BY-SA 4.0, lock tone synthesized). Self-check, exit 1 on failure: all five WAVs exist; both cycle files exactly 5.000 s; callout part ≤ 2.5 s; voice part of `cycletl.wav` ≤ 4.0 s
+- [X] T002 Run `python tools/voice/make_flameout.py --model ` (ask the user for the path to `en_US-amy-medium.onnx`; Speed Gauge's generator uses the same model) and confirm the self-check passes. Commit the generated `src/Apps/AG-FlmOt/` folder (FR-028: committed, CC BY-SA 4.0)
+- [X] T003 MANUAL: Listen to `src/Apps/AG-FlmOt/cycle.wav` and `cycletl.wav` on the PC: the callout sounds urgent and about one semitone higher than Speed Gauge's voice, the lock tone follows with no gap, and the files end cleanly at 5 s. Approve or ask for parameter changes before the app work depends on them
+- [X] T004 [P] Add a "Flameout Alarm" section to `tools/voice/README.md`: what `make_flameout.py` generates (the five files and their purpose), how to run it, the urgent-delivery chain (R5: length scale 0.749, resample ×1.07, high-pass, presence boost, compression) and that the output is committed under CC BY-SA 4.0
+- [X] T005 [P] Add the turbine sensor to `tools/emulator/sensors.json` (research R12): an entry `{"id": 3, "param": 0, "decimals": 0, "type": 1, "label": "Turbine", "unit": "", "sensorName": "Turbine"}` and `{"id": 3, "param": 1, "decimals": 0, "type": 1, "label": "RPM", "unit": "rpm", "sensorName": "Turbine", "lowerBound": 0, "upperBound": 160000, "input": "P8"}`. Keep the existing entries unchanged
+
+---
+
+## Phase 2: Foundational (Blocking Prerequisites)
+
+**Purpose**: The app file, settings storage, derived values, the tick and sensor read, a minimal settings form and a text-only window. Every story builds on these.
+
+**⚠️ CRITICAL**: No user story work can begin until this phase is complete
+
+- [X] T006 Create `src/Apps/AG-FlmOt.lua` with the header style of `src/Apps/AG-SpdGa.lua`: line 1 `-- AG-FlmOt.lua — Flameout Alarm: turbine flameout alarm from RPM telemetry, with an RPM bar window.`, then `-- Copyright (c) 2026 Aaron George` and `-- SPDX-License-Identifier: MIT`, a comment that the app only reads telemetry and switches, plays sound, vibrates and draws and never controls the model (constitution I), and that the sounds in `AG-FlmOt/` are CC BY-SA 4.0 (FR-028). Design pointer: `specs/003-flameout-alarm/`. Constants: `APP_NAME = "Flameout Alarm"`, `APP_VERSION = "0.1.0"`, `AUDIO_DIR = "/Apps/AG-FlmOt/"`, `TICK_MS = 100`, `CYCLE_MS = 5000`, `TITLE_H = 26`, a `SND` table with the five paths (`cycle`, `cycletl`, `tlost`, `armed`, `relit`), and state constants `OFF = 0`, `DISARMED = 1`, `ARMED = 2`, `FLAMEOUT = 3` (in one table). Debug flags `DEBUG_CPU = false`, `SIM = false`. End with `---@type JetiApp` and `return { init = init, loop = loop, destroy = destroy, author = "Aaron George", version = APP_VERSION, name = APP_NAME }`; `destroy()` stops alarm audio if one is sounding
+- [X] T007 Add settings in `src/Apps/AG-FlmOt.lua` per "Settings" in `specs/003-flameout-alarm/data-model.md`: a `KEYS` list and `DEFAULTS`/`LIM` tables with exactly: `en` 0/1 default 0; `sId` int default 0; `sPar` int default 0; `sLbl` "string < 64 bytes" default ""; `scl` "1–4" default 1; `swCut` SwitchItem default nil; `idle` "0–1500" default 0 ("Idle RPM / 100 (0 = not set)"); `maxR` "0–3000" default 0 ("0 = auto: 4 × idle"); `armP` "50–98" default 90; `flP` "20–95" default 70; `armT` "10–100" default 30; `detT` "3–50" default 10; `lossT` "5–100" default 20; `sayArm` 0/1 default 0; `sayRel` 0/1 default 0; `swTest` SwitchItem default nil; `cfgV` default 1 (17 keys, limit 30). `loadSettings()` uses `system.pLoad`, clamps every integer to its range, and applies the load rules: if `flP >= armP` restore both defaults; if `maxR ~= 0 and maxR <= idle` set `maxR = 0`; if `sId == 0`, `swCut == nil` or `idle == 0` force `en = 0`. A `save(key, value)` helper calls `system.pSave` and updates the in-memory table
+- [X] T008 Add `recompute()` in `src/Apps/AG-FlmOt.lua` per "Derived values" in data-model.md: `active = en == 1 and sId ~= 0 and swCut ~= nil and idle > 0`; `kScale` = 1, 10, 100, 1000 by `scl`; `idleRpm = idle * 100`; `armRpm = idleRpm * armP // 100`; `flRpm = idleRpm * flP // 100`; `fullRpm = maxR * 100` or `4 * idleRpm` when `maxR == 0`; `armMs = armT * 100`, `detMs = detT * 100`, `lossMs = lossT * 100`. Call it from `init()` after `loadSettings()` and after every settings change. If `active` becomes false while `state == FLAMEOUT`, stop the alarm (row 0)
+- [X] T009 Add session state and `resetSession()` in `src/Apps/AG-FlmOt.lua` per "Session state" in data-model.md: `state` (`OFF` if not `active`, else `DISARMED`), `rpm = nil`, `sensorFound = true`, `invalidSince = nil`, `lost = false`, `armSince = nil`, `lowSince = nil`, `cycleAt = 0`, `beepAt = nil`, `tlPending = false`, `testOn = false`, `testPrev = true` (so a test switch already on at load needs a toggle, spec edge case), `rpmText = "---"`, `rpmHundreds = -1`, `lastTick = system.getTimeCounter()`. Group them in one `st` table. Never persist them
+- [X] T010 Add audio-file checks in `src/Apps/AG-FlmOt.lua` (research R6): a `fileExists(path)` using Jeti's function-style `io.open`/`io.close` (as in `AG-SpdGa.lua`), called once in `init()` for the five `SND` paths. Set `audioOk = fileExists(cycle) and fileExists(cycletl)` and a `fileOk` table for `tlost`, `armed`, `relit`. Never call it from `loop()`
+- [X] T011 Implement the `loop()` frame in `src/Apps/AG-FlmOt.lua` per "State transitions" in data-model.md: return unless `now - lastTick >= TICK_MS`; call an empty `stepTest(now)` (US8 fills it); if not `active`, set `state = OFF` and return (row 0, FR-034); read the Cut switch (`system.getInputsVal(swCut) > 0.5`, research R10) and call an empty `onCut(now)` when in Cut (US2 fills it, row 1) then return; read `system.getSensorValueByID(sId, sPar)` through `system`: `nil` → `sensorFound = false`, invalid; `valid == false` → invalid; else `sensorFound = true`, `rpm = value * kScale`, `invalidSince = nil`, `lost = false`. On invalid call an empty `onInvalid(now)` (US6 fills it) and skip the rows below. On valid, dispatch by state to empty `stepDisarmed(now)`, `stepArmed(now)`, `stepFlameout(now)` (filled by US1/US4). Then, on valid and invalid ticks alike, when `state == FLAMEOUT` call an empty `stepCycle(now)` (US1), so the alarm keeps cycling through a telemetry loss (FR-020a). Keep the structure so later tasks only fill functions
+- [X] T012 Add the settings form frame in `src/Apps/AG-FlmOt.lua` per `specs/003-flameout-alarm/contracts/settings-form.md`: `system.registerForm(1, MENU_APPS, APP_NAME, initForm, nil, nil, closeForm)` in `init()`; `initForm` sets `formOpen = true`, `closeForm` sets it false. Add rows 1–5, 10 and 20 now: heading "Flameout Alarm"; Monitoring checkbox `en` with the FR-003 rule (refuse with `form.setValue` and show row 3's hint "Needs: RPM sensor, Cut switch, idle RPM" listing only the missing items); heading "Engine"; RPM sensor selectbox built from `system.getSensors()` on each open: "(none)" first, skip `param == 0`, `type == 5` and `type == 9`, entries "Device / Label", a saved sensor not present shown as " (not found)" (FR-022; same approach as `buildSensorList` in `AG-SpdGa.lua`); on change save `sId`, `sPar`, `sLbl` (cut to 63 bytes); Idle RPM (x1000) intbox `idle`, range 0–1500, 1 decimal, step 1, hint "From the ECU setup. Check against Live RPM at idle."; heading "Throttle cut" and Cut switch `form.addInputbox(swCut, false, ...)` with hint "Assign with the switch in the Cut (engine off) position." Every change calls `save()` then `recompute()`; clearing sensor, Cut switch or idle while enabled sets `en = 0`. Footer row (25) "Flameout Alarm 0.1.0. Advisory only; does not replace the ECU's failsafe." built from `APP_VERSION`. Labels ASCII only ("x", not "×")
+- [X] T013 Register the window in `init()` of `src/Apps/AG-FlmOt.lua`: `system.registerTelemetry(1, APP_NAME, 0, printRpm)`. For now `printRpm(w, h)` draws only the text layout of `specs/003-flameout-alarm/contracts/telemetry-window.md` in the theme foreground color: state label (OFF, NO SENSOR, NO TELEMETRY, DISARMED, ARMED, FLAMEOUT, TEST per "Window display state" in data-model.md) on line 1 and `rpmText` on line 2 (`FONT_NORMAL`). US7 replaces this for the DS-24 II
+- [X] T014 Run `python tools/check.py` and fix any errors in `src/Apps/AG-FlmOt.lua`. Check the VS Code Problems panel (LuaLS) shows no errors
+
+**Checkpoint**: The app loads, shows OFF, the form can set sensor, idle, Cut switch and enable, and `loop()` reads RPM. Nothing arms or sounds yet.
+
+---
+
+## Phase 3: User Story 1 - Hear a flameout the moment it happens (Priority: P1) 🎯 MVP
+
+**Goal**: Once the engine has held the arming threshold, RPM below the flameout threshold for the detection delay starts the repeating alarm with vibration and FLAMEOUT on screen.
+
+**Independent Test**: Emulator, Turbine RPM slider: above 31,500 for 3 s (idle 35.0), then to 0. The console shows `cycle.wav` with `AUDIO_IMMEDIATE` and two vibration lines within 1.5 s, then every 5 s (quickstart 6, 8, 9).
+
+- [X] T015 [US1] Add alarm audio helpers in `src/Apps/AG-FlmOt.lua` per `specs/003-flameout-alarm/contracts/audio-events.md` and research R2, R6, R7: `playCycle(now, file)` → if `audioOk` then `system.playFile(file, AUDIO_IMMEDIATE)`, else `system.playBeep(9, 1800, 120)` and `beepAt = now + 2500`; then `system.vibration(false, 4)` and `system.vibration(true, 4)`. `stopAlarm()` → `system.stopPlayback(AUDIO_IMMEDIATE)`, `beepAt = nil`, `tlPending = false`. In `stepCycle(now)`: if `beepAt` and `now >= beepAt` then `system.playBeep(9, 1800, 120)`, `beepAt = nil`
+- [X] T016 [US1] Fill `stepDisarmed(now)` in `src/Apps/AG-FlmOt.lua` (data-model row 3): `rpm >= armRpm` sets `armSince = armSince or now`; when `now - armSince >= armMs` set `state = ARMED`, `armSince = nil`, `lowSince = nil`. Below `armRpm` clears `armSince`. (The "armed" confirmation comes in US2)
+- [X] T017 [US1] Fill `stepArmed(now)` in `src/Apps/AG-FlmOt.lua` (row 4, FR-015): `rpm < flRpm` sets `lowSince = lowSince or now`; when `now - lowSince >= detMs` set `state = FLAMEOUT`, `cycleAt = now`, `armSince = nil`, `playCycle(now, SND.cycle)`. `rpm >= flRpm` clears `lowSince`
+- [X] T018 [US1] Fill the cycle timing in `stepCycle(now)` in `src/Apps/AG-FlmOt.lua` (row 6, SC-012): when `now - cycleAt >= CYCLE_MS`, advance `cycleAt = cycleAt + CYCLE_MS`, or `cycleAt = now` if still more than one cycle behind, then `playCycle(now, SND.cycle)` (US6 adds the `cycletl` choice). Make sure `stepCycle` also runs on ticks where the sample is invalid (FR-020a), i.e. called from the frame regardless of validity while `state == FLAMEOUT`
+- [X] T019 [US1] In the text layout of `printRpm` in `src/Apps/AG-FlmOt.lua`, show FLAMEOUT as the first line in `FONT_BIG` when `state == FLAMEOUT` (FR-031: dominant element)
+- [X] T020 [US1] MANUAL: Emulator, quickstart scenarios 6, 8 and 9: arming after 3 s above 31,500; a dip below 24,500 shorter than 1 s doesn't alarm; holding 0 alarms within 1.5 s and repeats every 5 s with vibration lines
+
+**Checkpoint**: The core alarm works end to end in the emulator.
+
+---
+
+## Phase 4: User Story 2 - Silence during normal operation (Priority: P1)
+
+**Goal**: No alarm through start, overshoot, idle, chops, landing and Cut shutdown; the app disarms silently on Cut and can confirm arming.
+
+**Independent Test**: The `SIM` normal-flight profile and a manual chop and Cut in the emulator produce no `cycle.wav` line (quickstart 7, 20; SC-002, SC-003).
+
+- [X] T021 [US2] Fill `onCut(now)` in `src/Apps/AG-FlmOt.lua` (row 1, FR-016): if `state == FLAMEOUT` call `stopAlarm()`; set `state = DISARMED`; clear `armSince`, `lowSince`, `invalidSince`, `tlPending`, `lost`. Silent: no sound of its own. Re-arming then needs the switch out of Cut and row 3 again
+- [X] T022 [US2] In `stepDisarmed` in `src/Apps/AG-FlmOt.lua`, after the switch to ARMED, play `SND.armed` with `AUDIO_QUEUE` when `sayArm == 1` and `fileOk.armed` (FR-009)
+- [X] T023 [US2] Add the `SIM` debug mode in `src/Apps/AG-FlmOt.lua` (research R12): when `SIM` is true, the sensor read in the `loop()` frame is replaced by a value from one `SIMP` table of profiles, each a list of `{ms, rpm}` points with linear interpolation and `rpm = -1` meaning invalid. Profiles: (1) normal flight: 0 → startup range 0–20,000 over 20 s, overshoot to 45,000, decay to 35,000 over 8 s, idle, run-ups to 120,000, chops to 35,000, landing, idle; (2) failed start: rises to 25,000 and falls to 0; (3) flameout: armed at 60,000 then falls to 0 over 3 s; (4) auto-restart: flameout, then 10,000 → 28,000 → 8,000 (failed attempt), then → 45,000 overshoot → 35,000; (5) dropouts: armed at 35,000 with invalid gaps of 0.5 s and 3 s. Select the profile with an extra selectbox row shown only when `SIM` is true, and restart the profile when selected. With `SIM` false none of this runs and the row isn't added
+- [X] T024 [US2] MANUAL: Emulator with `SIM` on: profile 1 and 2 give zero alarms (SC-002, SC-003); profile 3 alarms within 1.5 s of crossing 24,500. Without `SIM`: quickstart scenario 7 (chop to 30,000: no alarm) and Cut during ARMED (silent DISARMED). Set `SIM = false` afterwards
+
+**Checkpoint**: The alarm fires on a flameout and stays silent in normal operation.
+
+---
+
+## Phase 5: User Story 3 - Per-model setup with an assigned RPM sensor (Priority: P1)
+
+**Goal**: The complete settings form with validation, live RPM and per-model storage.
+
+**Independent Test**: Quickstart scenarios 1–5, 16 and 17.
+
+- [X] T025 [US3] Add form rows 6–9 in `src/Apps/AG-FlmOt.lua` per contracts/settings-form.md: hint row 6 shown when `system.getSensors()` returns no sensors ("No telemetry yet. The ECU must send RPM and the receiver must be on. Some adapters take up to a minute.", FR-004); hint row 7 shown when the selected sensor's unit, compared ignoring case, isn't `rpm`, `u/min` or `1/min` ("Unit is '', not RPM. Check the sensor, or set Sensor scale.", FR-010, R9; take the unit from `system.getSensorByID` at selection and form open, not in `loop()`); Sensor scale selectbox `scl` {"x1", "x10", "x100", "x1000"} (FR-006a); Live RPM label row 9 updated from `loop()` only while `formOpen`, at most every 500 ms, showing the scaled value with thousands separators or "---"
+- [X] T026 [US3] Add form row 11 in `src/Apps/AG-FlmOt.lua`: Max RPM (x1000) intbox `maxR`, range 0–3000, 1 decimal, step 1, "0.0 = auto (4 x idle)" hint; refuse a value `> 0 and <= idle` by setting the box back with `form.setValue` and showing the hint "Max RPM must be above idle." (FR-031b)
+- [X] T027 [US3] Add rows 12–15 in `src/Apps/AG-FlmOt.lua`: heading "Thresholds"; "Arming at % of idle" intbox `armP` 50–98 and "Flameout below % of idle" intbox `flP` 20–95, each with a label showing the RPM value (e.g. "= 31.5k"), rebuilt when idle or the percentage changes; refuse `flP >= armP` (set back, show row 15 "Flameout must be below arming, and arming below idle.") (FR-007)
+- [X] T028 [US3] Add rows 16–18 in `src/Apps/AG-FlmOt.lua`: "Arming time (s)" `armT` 10–100 step 5; "Detection delay (s)" `detT` 3–50 step 1; "Telemetry loss after (s)" `lossT` 5–100 step 5; all 1 decimal (FR-008)
+- [X] T029 [US3] Add rows 21–24 in `src/Apps/AG-FlmOt.lua`: heading "Announcements"; checkboxes Say "armed" (`sayArm`) and Say "relit" (`sayRel`); hint row 24 "Voice files missing: alarm uses beeps." when not `audioOk` (FR-029). Extend the footer with a second line "Voice: Piper, Amy (CC BY-SA 4.0)." (FR-028)
+- [X] T030 [US3] Review the enable rule in `src/Apps/AG-FlmOt.lua` across all rows: turning monitoring on with any requirement missing is refused and row 3 names the missing items; removing a requirement while enabled sets `en = 0`, calls `recompute()` (alarm stops if sounding, state OFF) and shows row 3 (FR-003, FR-012)
+- [X] T031 [US3] MANUAL: Emulator, quickstart scenarios 1–5, 16 and 17 (default OFF, enable blocked, no-sensors hint, full setup in under 3 minutes, threshold refusals, sensor not found, per-model settings across restart)
+
+**Checkpoint**: All three P1 stories work: the MVP.
+
+---
+
+## Phase 6: User Story 4 - Auto-restart and relight (Priority: P2)
+
+**Goal**: The alarm keeps going through restart attempts and clears only when RPM holds the arming threshold for the arming time.
+
+**Independent Test**: `SIM` profile 4 and quickstart scenarios 10–11 (SC-011).
+
+- [X] T032 [US4] Fill `stepFlameout(now)` in `src/Apps/AG-FlmOt.lua` (row 5, FR-017, FR-018): `rpm >= armRpm` sets `armSince = armSince or now`; when `now - armSince >= armMs`: `stopAlarm()`, `state = ARMED`, `armSince = nil`, `lowSince = nil`, then play `SND.relit` with `AUDIO_QUEUE` if `sayRel == 1` and `fileOk.relit`. `rpm < armRpm` (the startup range) clears `armSince` and starts nothing new
+- [X] T033 [US4] MANUAL: Emulator, `SIM` profile 4: the failed attempt doesn't clear the alarm and no second event starts; the relight clears within 3.5 s of holding 31,500 and stays clear through the decay to 35,000. Without `SIM`: quickstart scenarios 10 and 11
+
+---
+
+## Phase 7: User Story 5 - Stop the alarm with the Cut switch (Priority: P2)
+
+**Goal**: Cut silences the alarm at once and needs a fresh arming before another alarm.
+
+**Independent Test**: Quickstart scenario 12.
+
+- [X] T034 [US5] Check in `src/Apps/AG-FlmOt.lua` that `onCut` (T021) runs before any other row on the same tick, that it stops both the voice/tone file and pending fallback beeps (`beepAt`), and that after Cut the app stays DISARMED with low RPM and re-arms only after leaving Cut and holding `armRpm` for `armMs` (User Story 5 scenarios 1–3). Fix anything that doesn't
+- [X] T035 [US5] MANUAL: Emulator, quickstart scenario 12, including Cut during the 2.5 s fallback beep wait (rename `cycle.wav` for that part)
+
+---
+
+## Phase 8: User Story 6 - Tell telemetry loss apart from a flameout (Priority: P2)
+
+**Goal**: Loss after the telemetry-loss delay gives "Engine telemetry lost", never the flameout alarm; during an alarm it's spoken once inside the next cycle.
+
+**Independent Test**: Quickstart scenarios 13–16 (SC-004).
+
+- [X] T036 [US6] Fill `onInvalid(now)` in `src/Apps/AG-FlmOt.lua` (row 2, FR-020, FR-020a): `invalidSince = invalidSince or now`; clear `armSince` and `lowSince`; `rpm = nil`; when `now - invalidSince >= lossMs` and not `lost`: `lost = true`; if `state == ARMED` and `fileOk.tlost`, play `SND.tlost` with `AUDIO_IMMEDIATE`; if `state == FLAMEOUT` set `tlPending = true`. Nothing happens while DISARMED besides the display
+- [X] T037 [US6] In `stepCycle` in `src/Apps/AG-FlmOt.lua`, play `SND.cycletl` instead of `SND.cycle` when `tlPending`, then clear `tlPending` (research R4). With `audioOk` false the beeps continue unchanged
+- [X] T038 [US6] In the frame of `loop()` in `src/Apps/AG-FlmOt.lua`, confirm that a valid sample after a loss resumes from the current RPM (FR-021): ARMED stays ARMED when `rpm >= flRpm`, and detection restarts from zero otherwise (`lowSince` was cleared). In `printRpm`'s text layout show NO SENSOR when `not sensorFound`, NO TELEMETRY when `lost` (not FLAMEOUT), and "NO TELEMETRY" as the second line under FLAMEOUT when both
+- [X] T039 [US6] MANUAL: Emulator, quickstart scenarios 13–16 (slider fully down = invalid; sensor removed from `sensors.json`)
+
+---
+
+## Phase 9: User Story 7 - RPM bar display (Priority: P2)
+
+**Goal**: The double-size segmented sweep and the single-size straight bar of the window contract, in Speed Gauge's colors.
+
+**Independent Test**: Quickstart scenarios 19 and 21 in the emulator; 22 on the transmitter (SC-013).
+
+- [X] T040 [US7] Add window constants in `src/Apps/AG-FlmOt.lua` per `specs/003-flameout-alarm/contracts/telemetry-window.md`, grouped in tables: colors (background 8,10,14; lit/ARMED 0,190,255; unlit fill 34,38,44 and outline 70,78,90; idle marker 255,225,0; number 255,255,255; labels 170,170,170; banner 255,45,30) and sweep geometry (`N = 12`, `X0 = 4`, `X1 = 146`, `GAP = 2`, bottom `y = 22 + 40 * (1 - t)^2.2`, height `6 + 12 * t`). Red only for FLAMEOUT (FR-031a)
+- [X] T041 [US7] In `init()` of `src/Apps/AG-FlmOt.lua`, precompute the 12 segments' four corner points as integers (the same formulas as `specs/003-flameout-alarm/design/mockup.py`'s `sweep()`), plus each segment's `t` at its left edge for the idle marker. Detect the DS-24 II once: `string.find(system.getDeviceType() or "", "24 II", 1, true)`. Recompute `idleX` (marker x) and its marker top/bottom in `recompute()`
+- [X] T042 [US7] Add `rpmText` caching in `src/Apps/AG-FlmOt.lua`: in `loop()`, when `rpm // 100` differs from `rpmHundreds`, rebuild `rpmText` as the RPM with comma thousands separators ("112,400"); "---" when `rpm` is nil. No other string work per tick
+- [X] T043 [US7] Implement the double layout in `printRpm` of `src/Apps/AG-FlmOt.lua`: layout selection (`w == 157` → `h = h - TITLE_H` and offset x by 3; not DS-24 II → text layout; `h >= 45` → double; else single); one `lcd.renderer()` created on first draw and reused; background fill; segments `1..lit` as cyan filled polygons and the rest as dim fills with a 1 px opaque outline polyline, `lit = math.min(N, math.ceil(N * rpm / fullRpm))`, 0 without data; idle marker as a 2 px filled rectangle from 3 px below to 3 px above the sweep at `idleX`; state label at x 4, y 2 `FONT_MINI` (colors per state, TEST white); RPM number right-aligned at x 146, top y 32, `FONT_BIG`; "RPM" right-aligned at x 146, top y 54, `FONT_MINI`; during FLAMEOUT a red filled 142 × 24 banner at x 4, y 2 with "FLAMEOUT" centered in `FONT_BIG` white, and "NO TELEMETRY" in place of "RPM" when `lost`
+- [X] T044 [US7] Implement the single layout in `printRpm` of `src/Apps/AG-FlmOt.lua`: bar x 4, y 4, 84 × 8 with dim fill and cyan fill to `84 * rpm / fullRpm` (capped); idle marker 2 px wide from y 1 to 15; state label x 4, y 12 `FONT_MINI`; RPM number right-aligned at x 146, top y 2, `FONT_NORMAL`; FLAMEOUT: whole window red, "FLAMEOUT" in `FONT_NORMAL` white on the left, number on the right
+- [X] T045 [US7] Add the `DEBUG_CPU` readout in `src/Apps/AG-FlmOt.lua`: when true, the double layout shows this call's and the worst `system.getCPU()` in `FONT_MINI` at the bottom left (as Speed Gauge's flag). Off by default
+- [X] T046 [US7] MANUAL: Emulator, quickstart scenario 19 at double and single size in every state, and scenario 21 (30 minutes with `DEBUG_CPU`, note the worst figure). Transmitter, scenario 22: screenshot at both sizes and compare with `design/rpm-window-mockup.svg`. If the sweep reads poorly or costs too much, iterate the named constants, drop the outlines, or switch to the straight-bar fallback (FR-031)
+
+---
+
+## Phase 10: User Story 8 - Test the alarm from a switch (Priority: P3)
+
+**Goal**: A test switch plays the real alarm while on, whenever the app isn't Armed or in Flameout.
+
+**Independent Test**: Quickstart 19a in the emulator; transmitter checks 23–26.
+
+- [X] T047 [US8] Add form row 23a in `src/Apps/AG-FlmOt.lua`: "Test alarm" `form.addInputbox(swTest, false, ...)` with hint "Plays the alarm while on. Not while armed." (FR-030a); saving `swTest` needs no recompute
+- [X] T048 [US8] Fill `stepTest(now)` in `src/Apps/AG-FlmOt.lua` per the "Test alarm" table in data-model.md (research R13): `T = swTest ~= nil and system.getInputsVal(swTest) > 0.5`; T1: `T and not testPrev`, `state` is OFF or DISARMED (OFF also when not `active`), not `testOn` → `testOn = true`, `cycleAt = now`, `playCycle(now, SND.cycle)`; T2: `testOn` and `now - cycleAt >= CYCLE_MS` → next cycle as in `stepCycle` (also fire a pending `beepAt`); T3: `testOn` and (not `T`, or `state` is ARMED or FLAMEOUT) → `testOn = false`, `stopAlarm()`; always `testPrev = T`. Runs before the `active` check so it works with monitoring off. In both window layouts show TEST (white) when `testOn`
+- [X] T049 [US8] MANUAL: Emulator, quickstart 19a. Transmitter, checks 23–26: hold the test switch for the alarm sound and rhythm, Speed Gauge pre-emption, instant stop, fallback beeps (note the beep gap)
+
+---
+
+## Phase 11: Polish & Cross-Cutting Concerns
+
+**Purpose**: Documentation, credits, audits and the release checks
+
+- [X] T050 [P] Update `README.md`: add Flameout Alarm to the apps table (script `AG-FlmOt.lua`, spec 003, original work); a section stating it is advisory, does not replace the ECU's failsafe, shutdown or auto-restart logic nor the pilot's monitoring, and only works where RPM reaches the transmitter as a normal telemetry sensor; the per-brand support list from research R1 (JetCat, Xicoy, KingTech, Swiwin via VSpeak confirmed; JetCentral, Enjet, Swiwin direct unconfirmed; JetiBox-only or vendor-app-only RPM can't be used); setup guidance (FR-035): idle from the ECU setup, the start overshoot and why arming sits below idle, the arming threshold staying above any RPM before the engine runs on its own, effects of an idle typed too high or too low; the test alarm switch; Swiwin's output-period note; install = `AG-FlmOt.lua` + `AG-FlmOt/`; the sounds' CC BY-SA 4.0 license
+- [X] T051 [P] Update `CREDITS.md`: a Flameout Alarm section for the generated sounds in `src/Apps/AG-FlmOt/` (Piper MIT, Amy model CC BY-SA 4.0 by Mycroft / Rhasspy, files distributed under CC BY-SA 4.0), following the Speed Gauge voice entry's wording (FR-028)
+- [X] T052 [P] Update `CLAUDE.md`'s "Apps in this repo" table: `Flameout Alarm | src/Apps/AG-FlmOt.lua | specs/003-flameout-alarm/ | original`
+- [X] T053 Audit `src/Apps/AG-FlmOt.lua`: no `registerControl`, `setControl`, `setProperty`, `os.`, `debug.`, `coroutine`, `bit32`; `SIM = false` and `DEBUG_CPU = false`; every variable `local`; main-chunk locals well under 200 (LuaLS `local-limit`); no allocation in `loop()`/`printRpm` beyond `rpmText` and the form's live row. Run `python tools/check.py` (0 errors) and LuaLS (no errors)
+- [ ] T054 MANUAL: Transmitter checks 27–28 (telemetry-loss timing with the receiver off; install on a card with no other AG- apps, SC-007) and quickstart section 4 bench test with a real ECU (start overshoot never drops below the arming threshold, Cut shutdown silent, sensor label and unit). Record a Jeti log of a start and shutdown for future defaults
+- [ ] T055 Record the transmitter findings from T046, T049 and T054 in `docs/jeti-api-notes.md` (gap between `playBeep` repeats, firmware telemetry "valid" timeout, sweep CPU figure, whether `stopPlayback(AUDIO_IMMEDIATE)` cuts mid-file cleanly) and in `specs/003-flameout-alarm/research.md` where they settle an open point
+
+- [X] T056 [P] Add the JETI Studio description page for Flameout Alarm, following Speed Gauge's: `docs/apps/flameout-alarm.md` and `docs/apps/flameout-alarm.html` (what it does, advisory note, ECU support, setup, test alarm switch, CC BY-SA 4.0 sounds), with transmitter screenshots of the double and single windows in `docs/apps/img/` (from T046). Link it from the README's "Install with JETI Studio" section
+- [X] T057 [P] Make the app icon `docs/apps/img/flameout-alarm-icon.png` with `tools/publish/make_icon.py` (add an option or constant for the output path and design rather than overwriting Speed Gauge's icon; keep the "AG" monogram style)
+- [ ] T058 After the release tag `AG-FlmOt-v0.1.0` exists (CLAUDE.md branch rules), add a Flameout Alarm entry to `APPS` in `tools/publish/make_apps_json.py` (script `AG-FlmOt`, files `src/Apps/AG-FlmOt.lua` and `src/Apps/AG-FlmOt`, description and icon from T056/T057), run it, and commit `Apps.json` for the merge to `main`. Try it first with `--local` in JETI Studio
+
+---
+
+## Dependencies & Execution Order
+
+### Phase Dependencies
+
+- **Setup (Phase 1)**: T001 → T002 → T003. T004 and T005 any time. The app can be built before T002 (it falls back to beeps), but T020 onward listen for real files only on the transmitter.
+- **Foundational (Phase 2)**: depends on Phase 1's T005 for emulator testing only. Blocks all stories.
+- **US1 (Phase 3)**: after Phase 2. The MVP core.
+- **US2 (Phase 4)**: after US1 (fills `onCut`, uses arming and the alarm).
+- **US3 (Phase 5)**: after Phase 2; independent of US1/US2 in code, but run after them since all share one file.
+- **US4, US5, US6 (Phases 6–8)**: after US1 (they extend `stepFlameout`, `onCut` and `onInvalid`); US5 after US2.
+- **US7 (Phase 9)**: after Phase 2 (only replaces the print function); best after US6 so every state exists to draw.
+- **US8 (Phase 10)**: after US1 (reuses `playCycle`/`stopAlarm`).
+- **Polish (Phase 11)**: after the stories; T050–T052 can start any time.
+
+### Within Each User Story
+
+- Code tasks in order (one file), then the MANUAL check.
+- Run `python tools/check.py` at the end of each phase.
+
+### Parallel Opportunities
+
+- T001, T004, T005 (different files) in Phase 1.
+- T050, T051, T052 (README, CREDITS, CLAUDE.md) alongside any app phase.
+- MANUAL checks of one phase can run while the next phase's code is written.
+
+---
+
+## Parallel Example: Setup and docs
+
+```text
+Task: "T001 Create tools/voice/make_flameout.py"
+Task: "T005 Add the turbine sensor to tools/emulator/sensors.json"
+Task: "T050 Update README.md"
+Task: "T051 Update CREDITS.md"
+```
+
+---
+
+## Implementation Strategy
+
+### MVP First (P1 stories: US1–US3)
+
+1. Phase 1 (sounds, emulator sensor) and Phase 2 (app frame).
+2. US1: the alarm. **Validate** in the emulator (T020).
+3. US2: no false alarms, Cut. **Validate** with `SIM` (T024).
+4. US3: full settings. **Validate** (T031). This is a usable alarm.
+
+### Incremental Delivery
+
+5. US4 relight, US5 Cut stop, US6 telemetry loss: each validated in the emulator.
+6. US7 the sweep window: emulator, then the transmitter screenshot.
+7. US8 test switch: then hear everything on the transmitter (T049).
+8. Polish, transmitter and bench checks, docs. Release as `AG-FlmOt-v0.1.0` after a PR to develop and then main (CLAUDE.md).
+
+---
+
+## Notes
+
+- [P] tasks = different files, no dependencies.
+- MANUAL tasks stay unticked until the user reports the result.
+- Commit after each phase.
+- The filename `AG-FlmOt.lua` becomes permanent at the first release (constitution V).
diff --git a/src/Apps/AG-FlmOt.lua b/src/Apps/AG-FlmOt.lua
new file mode 100644
index 0000000..9af11b1
--- /dev/null
+++ b/src/Apps/AG-FlmOt.lua
@@ -0,0 +1,1082 @@
+-- AG-FlmOt.lua — Flameout Alarm: turbine flameout alarm from RPM telemetry, with an RPM bar window.
+-- Copyright (c) 2026 Aaron George
+-- SPDX-License-Identifier: MIT
+--
+-- Listen-and-inform only: it reads one RPM sensor and two switches, plays
+-- sound, vibrates the sticks and draws. It never controls the model or the
+-- engine (constitution principle I). The sounds in /Apps/AG-FlmOt/ are
+-- licensed CC BY-SA 4.0 (Piper "Amy" voice), not MIT (FR-028).
+--
+-- Design: specs/003-flameout-alarm/ (plan.md, research.md, data-model.md,
+-- contracts/). Research decisions are cited as R1..R13.
+
+local APP_NAME = "Flameout Alarm"
+local APP_VERSION = "0.1.0"
+local AUDIO_DIR = "/Apps/AG-FlmOt/"
+local TICK_MS = 100 -- logic runs at most 10x per second (R8)
+local CYCLE_MS = 5000 -- one alarm cycle, callout start to callout start (FR-023)
+-- The JETI Studio emulator draws each window's title bar inside the reported
+-- canvas (157 x 127 / 157 x 60, ~26 px hidden); the transmitter draws it
+-- above and reports 150 x 68 / 150 x 23 (Speed Gauge, 2026-10-03).
+local TITLE_H = 26
+
+-- Sound files, generated by tools/voice/make_flameout.py (R5).
+local SND = {
+ cycle = AUDIO_DIR .. "cycle.wav", -- "Flameout!" x3 + lock tone, 5.000 s
+ cycletl = AUDIO_DIR .. "cycletl.wav", -- the same with "Engine telemetry lost"
+ tlost = AUDIO_DIR .. "tlost.wav",
+ armed = AUDIO_DIR .. "armed.wav",
+ relit = AUDIO_DIR .. "relit.wav",
+}
+
+-- Engine monitor states (data-model.md).
+local S = { OFF = 0, DISARMED = 1, ARMED = 2, FLAMEOUT = 3 }
+
+local TXT = {
+ off = "OFF", noSensor = "NO SENSOR", noTel = "NO TELEMETRY", disarmed = "DISARMED",
+ armed = "ARMED", flameout = "FLAMEOUT", test = "TEST", noData = "---",
+}
+
+-- Debug switches, both off for release (R11, R12).
+local DEBUG_CPU = false -- show per-call CPU on the double-size window
+local SIM = false -- replace the RPM sensor with scripted profiles
+
+------------------------------------------------------------------------------
+-- Settings (persisted per model; data-model.md). Integers, one string and
+-- SwitchItems only; booleans as 0/1, times in 0.1 s, RPM in hundreds
+-- (constitution IV).
+------------------------------------------------------------------------------
+
+local KEYS = {
+ "en", "sId", "sPar", "sLbl", "scl", "swCut", "idle", "maxR", "armP", "flP",
+ "armT", "detT", "lossT", "sayArm", "sayRel", "swTest", "cfgV",
+} -- 17 keys (limit 30)
+local DEFAULTS = {
+ en = 0, sId = 0, sPar = 0, sLbl = "", scl = 1, idle = 0, maxR = 0, armP = 90,
+ flP = 70, armT = 30, detT = 10, lossT = 20, sayArm = 0, sayRel = 0, cfgV = 1,
+} -- swCut and swTest default to nil
+local RANGES = {
+ en = { 0, 1 }, scl = { 1, 4 }, idle = { 0, 1500 }, maxR = { 0, 3000 },
+ armP = { 50, 98 }, flP = { 20, 95 }, armT = { 10, 100 }, detT = { 3, 50 },
+ lossT = { 5, 100 }, sayArm = { 0, 1 }, sayRel = { 0, 1 },
+}
+local SCALES = { 1, 10, 100, 1000 } -- sensor scale x1 .. x1000 (FR-006a)
+
+local cfg = {}
+
+local function save(key, value)
+ cfg[key] = value
+ system.pSave(key, value)
+end
+
+local function clamp(v, lo, hi)
+ if v < lo then
+ return lo
+ elseif v > hi then
+ return hi
+ end
+ return v
+end
+
+local function loadSettings()
+ for _, key in ipairs(KEYS) do
+ cfg[key] = system.pLoad(key, DEFAULTS[key])
+ end
+ for key, r in pairs(RANGES) do
+ local v = clamp(cfg[key], r[1], r[2])
+ if v ~= cfg[key] then
+ save(key, v)
+ end
+ end
+ -- Combinations the form never allows (data-model.md "Validation").
+ if cfg.flP >= cfg.armP then
+ save("armP", DEFAULTS.armP)
+ save("flP", DEFAULTS.flP)
+ end
+ if cfg.maxR ~= 0 and cfg.maxR <= cfg.idle then
+ save("maxR", 0)
+ end
+ if cfg.en == 1 and (cfg.sId == 0 or cfg.swCut == nil or cfg.idle == 0) then
+ save("en", 0)
+ end
+end
+
+------------------------------------------------------------------------------
+-- Derived values: recomputed only when a setting changes, never per tick.
+------------------------------------------------------------------------------
+
+local d = {
+ active = false, -- monitoring on and fully configured (FR-003, FR-012)
+ kScale = 1, -- sensor value -> RPM
+ idleRpm = 0, armRpm = 0, flRpm = 0, fullRpm = 1,
+ armMs = 3000, detMs = 1000, lossMs = 2000,
+ ver = 0, -- bumped on every recompute; the window follows it
+}
+
+local function recompute()
+ d.active = cfg.en == 1 and cfg.sId ~= 0 and cfg.swCut ~= nil and cfg.idle > 0
+ d.kScale = SCALES[cfg.scl]
+ d.idleRpm = cfg.idle * 100
+ d.armRpm = d.idleRpm * cfg.armP // 100
+ d.flRpm = d.idleRpm * cfg.flP // 100
+ if cfg.maxR ~= 0 then
+ d.fullRpm = cfg.maxR * 100
+ else
+ d.fullRpm = math.max(1, 4 * d.idleRpm)
+ end
+ d.armMs = cfg.armT * 100
+ d.detMs = cfg.detT * 100
+ d.lossMs = cfg.lossT * 100
+ d.ver = d.ver + 1
+end
+
+------------------------------------------------------------------------------
+-- Session state (not persisted; reset by init on every model load).
+------------------------------------------------------------------------------
+
+local st = {}
+
+local function resetSession()
+ st.state = d.active and S.DISARMED or S.OFF
+ st.rpm = nil -- last valid RPM; nil while invalid
+ st.sensorFound = true -- false: the sensor isn't in the model's list (FR-022)
+ st.invalidSince = nil
+ st.lost = false -- invalid for at least lossMs (NO TELEMETRY)
+ st.armSince = nil -- RPM >= armRpm since (DISARMED, FLAMEOUT)
+ st.lowSince = nil -- RPM < flRpm since (ARMED)
+ st.cycleAt = 0 -- start of the current alarm cycle
+ st.beepAt = nil -- second fallback beep burst due (R6)
+ st.tlPending = false -- play cycletl.wav at the next cycle (R4)
+ st.testOn = false -- a test alarm is playing (R13)
+ st.testPrev = true -- a test switch already on at load needs a toggle
+ st.rpmText = TXT.noData
+ st.rpmHundreds = -1
+ st.lastTick = system.getTimeCounter()
+ st.liveAt = 0 -- last Live RPM row update (form open only)
+end
+
+------------------------------------------------------------------------------
+-- Audio (contracts/audio-events.md)
+------------------------------------------------------------------------------
+
+-- Which files exist, checked once in init() (R6).
+local audio = { ok = false, tlost = false, armed = false, relit = false }
+
+-- Jeti's io is function-style (constitution III). The transmitter opens
+-- "/Apps/..."; the JETI Studio emulator (6.04) only opens the same path
+-- without the leading "/" (2026-10-05), so try that too. Only this check
+-- uses it: playback keeps the absolute path.
+local function fileExists(path)
+ local f = io.open(path, "r") or io.open(string.sub(path, 2), "r")
+ if f then
+ io.close(f)
+ return true
+ end
+ return false
+end
+
+local function checkAudio()
+ audio.ok = fileExists(SND.cycle) and fileExists(SND.cycletl)
+ audio.tlost = fileExists(SND.tlost)
+ audio.armed = fileExists(SND.armed)
+ audio.relit = fileExists(SND.relit)
+end
+
+-- Starts one alarm cycle (R2, R6, R7). AUDIO_IMMEDIATE stops any other
+-- foreground playback, so the alarm pre-empts other apps' speech (FR-025).
+-- Without the cycle files: two bursts of beeps, the second 2.5 s later.
+local function playCycle(now, file)
+ if audio.ok then
+ system.playFile(file, AUDIO_IMMEDIATE)
+ else
+ system.playBeep(9, 1800, 120)
+ st.beepAt = now + 2500
+ end
+ system.vibration(false, 4)
+ system.vibration(true, 4)
+end
+
+-- Stops alarm sound at once, mid-callout or mid-beep (R3).
+local function stopAlarm()
+ system.stopPlayback(AUDIO_IMMEDIATE)
+ st.beepAt = nil
+ st.tlPending = false
+end
+
+------------------------------------------------------------------------------
+-- Engine monitor (data-model.md "State transitions"). One function per row
+-- group; each user story fills its own.
+------------------------------------------------------------------------------
+
+-- SIM (debug, R12): scripted RPM profiles in place of the sensor, chosen in
+-- the form while SIM is true. Each profile is a flat list of time (ms from
+-- selection), RPM pairs, linearly interpolated; RPM -1 means invalid data.
+-- The last value holds after the end.
+local sim = { idx = 1, t0 = 0, out = { value = 0, valid = true } }
+sim.names = { "Off (real sensor)", "Normal flight", "Failed start", "Flameout", "Auto-restart", "Dropouts" }
+sim.profiles = { -- sim.names[i + 1]
+ { 0, 0, 5000, 0, 25000, 20000, 28000, 45000, 36000, 35000, 60000, 35000, 65000, 120000,
+ 90000, 120000, 92000, 35000, 100000, 35000, 103000, 120000, 130000, 120000,
+ 132000, 35000, 160000, 35000 },
+ { 0, 0, 5000, 0, 20000, 25000, 23000, 25000, 30000, 0 },
+ { 0, 0, 3000, 0, 15000, 40000, 20000, 60000, 40000, 60000, 43000, 0 },
+ { 0, 60000, 8000, 60000, 11000, 0, 16000, 0, 20000, 10000, 24000, 28000, 27000, 8000,
+ 30000, 8000, 34000, 20000, 38000, 45000, 46000, 35000 },
+ { 0, 35000, 10000, 35000, 10001, -1, 10500, -1, 10501, 35000, 15000, 35000, 15001, -1,
+ 18000, -1, 18001, 35000 },
+}
+
+local function simRead(now)
+ local p = sim.profiles[sim.idx - 1]
+ local t = now - sim.t0
+ local v = p[#p]
+ for i = 1, #p - 2, 2 do
+ if t < p[i + 2] then
+ local a, b = p[i + 1], p[i + 3]
+ if a < 0 or b < 0 then
+ v = -1
+ else
+ v = a + (b - a) * (t - p[i]) / (p[i + 2] - p[i])
+ end
+ break
+ end
+ end
+ sim.out.valid = v >= 0
+ sim.out.value = v
+ return sim.out
+end
+
+-- Unassigned switch = off. "On" is above half travel (R10).
+local function switchOn(item)
+ if item == nil then
+ return false
+ end
+ local v = system.getInputsVal(item)
+ return v ~= nil and v > 0.5
+end
+
+-- Row 1 (FR-016): Cut disarms at once and silently from any state. Arming
+-- again needs the switch out of Cut and row 3 (User Story 5).
+local function onCut()
+ if st.state == S.FLAMEOUT then
+ stopAlarm()
+ end
+ st.state = S.DISARMED
+ st.armSince, st.lowSince, st.invalidSince = nil, nil, nil
+ st.tlPending, st.lost = false, false
+end
+
+-- Row 2 (FR-020, FR-020a): no valid data. Timers that could complete an
+-- arming, a flameout or a relight are cleared, so a loss never causes one.
+-- After the telemetry-loss delay: "Engine telemetry lost" once while ARMED,
+-- or once inside the next alarm cycle while FLAMEOUT (R4).
+local function onInvalid(now)
+ st.invalidSince = st.invalidSince or now
+ st.armSince, st.lowSince = nil, nil
+ if not st.lost and now - st.invalidSince >= d.lossMs then
+ st.lost = true
+ if st.state == S.ARMED then
+ if audio.tlost then
+ system.playFile(SND.tlost, AUDIO_IMMEDIATE)
+ end
+ elseif st.state == S.FLAMEOUT then
+ st.tlPending = true
+ end
+ end
+end
+
+-- Row 3 (FR-014): arms once RPM has held the arming threshold for the
+-- arming time. The start overshoot above idle counts like any RPM above it.
+local function stepDisarmed(now)
+ if st.rpm >= d.armRpm then
+ st.armSince = st.armSince or now
+ if now - st.armSince >= d.armMs then
+ st.state = S.ARMED
+ st.armSince, st.lowSince = nil, nil
+ if cfg.sayArm == 1 and audio.armed then
+ system.playFile(SND.armed, AUDIO_QUEUE) -- FR-009; queued, never cuts a callout
+ end
+ end
+ else
+ st.armSince = nil
+ end
+end
+
+-- Row 4 (FR-015): RPM below the flameout threshold for the detection delay.
+local function stepArmed(now)
+ if st.rpm < d.flRpm then
+ st.lowSince = st.lowSince or now
+ if now - st.lowSince >= d.detMs then
+ st.state = S.FLAMEOUT
+ st.lowSince, st.armSince = nil, nil
+ st.cycleAt = now
+ playCycle(now, SND.cycle)
+ end
+ else
+ st.lowSince = nil
+ end
+end
+
+-- Row 5 (FR-017, FR-018): the alarm clears only when RPM holds the arming
+-- threshold for the arming time, usually during the relight overshoot.
+-- RPM in the startup range (an auto-restart cranking) breaks the timer and
+-- starts nothing new; the same alarm continues.
+local function stepFlameout(now)
+ if st.rpm >= d.armRpm then
+ st.armSince = st.armSince or now
+ if now - st.armSince >= d.armMs then
+ stopAlarm()
+ st.state = S.ARMED
+ st.armSince, st.lowSince = nil, nil
+ if cfg.sayRel == 1 and audio.relit then
+ system.playFile(SND.relit, AUDIO_QUEUE)
+ end
+ end
+ else
+ st.armSince = nil
+ end
+end
+
+-- Row 6 (SC-012): a new cycle every 5 s, plus the second fallback beep burst.
+local function stepCycle(now)
+ if st.beepAt and now >= st.beepAt then
+ system.playBeep(9, 1800, 120)
+ st.beepAt = nil
+ end
+ if now - st.cycleAt >= CYCLE_MS then
+ st.cycleAt = st.cycleAt + CYCLE_MS
+ if now - st.cycleAt >= CYCLE_MS then
+ st.cycleAt = now -- fell more than a cycle behind: restart the rhythm
+ end
+ if st.tlPending then -- "Engine telemetry lost" inside this cycle, once (R4)
+ st.tlPending = false
+ playCycle(now, SND.cycletl)
+ else
+ playCycle(now, SND.cycle)
+ end
+ end
+end
+
+-- Test alarm, rows T1-T3 (FR-030a, R13): the real alarm while the test
+-- switch is on, through the same cycle code. Starts only on the switch's
+-- off-to-on change and only while OFF or DISARMED, so it can't mask or
+-- imitate a real alarm, and stops as soon as the app arms. Runs before the
+-- OFF check in loop(): it works with monitoring off.
+local function stepTest(now)
+ local on = switchOn(cfg.swTest)
+ local quiet = st.state ~= S.ARMED and st.state ~= S.FLAMEOUT
+ if st.testOn then
+ if not on or not quiet then
+ st.testOn = false -- T3
+ stopAlarm()
+ else
+ stepCycle(now) -- T2: next cycle every 5 s, fallback beeps
+ end
+ elseif on and not st.testPrev and quiet then
+ st.testOn = true -- T1
+ st.tlPending = false
+ st.cycleAt = now
+ playCycle(now, SND.cycle)
+ end
+ st.testPrev = on
+end
+
+-- Whole RPM with a thousands separator: 112400 -> "112,400".
+local function fmtRpm(r)
+ if r >= 1000 then
+ return string.format("%d,%03d", r // 1000, r % 1000)
+ end
+ return tostring(r)
+end
+
+-- "112,400" for the window, rebuilt only when RPM / 100 changes.
+local function updateRpmText()
+ local h = -1
+ if st.rpm ~= nil then
+ h = math.floor(st.rpm / 100 + 0.5)
+ end
+ if h == st.rpmHundreds then
+ return
+ end
+ st.rpmHundreds = h
+ if h < 0 then
+ st.rpmText = TXT.noData
+ else
+ st.rpmText = fmtRpm(h * 100)
+ end
+end
+
+------------------------------------------------------------------------------
+-- Settings form (contracts/settings-form.md)
+------------------------------------------------------------------------------
+
+local sensorLabels, sensorIds, sensorPars, sensorUnits = {}, {}, {}, {}
+local notFoundIdx = 0
+local formOpen = false -- loop may touch form rows only while true (R10)
+local idx = {} -- component indices of rows updated after creation
+
+local LABEL_W = 210
+
+local function heading(text)
+ form.addRow(1)
+ form.addLabel({ label = text, font = FONT_BOLD })
+end
+
+local function hint(text, visible)
+ form.addRow(1)
+ return form.addLabel({ label = text, font = FONT_MINI, visible = visible ~= false })
+end
+
+-- What monitoring still needs (FR-003); "" when complete.
+local function missingText()
+ local parts = {}
+ if cfg.sId == 0 then
+ parts[#parts + 1] = "RPM sensor"
+ end
+ if cfg.swCut == nil then
+ parts[#parts + 1] = "Cut switch"
+ end
+ if cfg.idle == 0 then
+ parts[#parts + 1] = "idle RPM"
+ end
+ if #parts == 0 then
+ return ""
+ end
+ return "Needs: " .. table.concat(parts, ", ")
+end
+
+local function updateNeeds()
+ if idx.needs then
+ local text = missingText()
+ form.setProperties(idx.needs, { label = text, visible = text ~= "" })
+ end
+end
+
+-- A requirement changed: monitoring turns itself off if one is now missing.
+local function onRequirementChanged()
+ if cfg.en == 1 and missingText() ~= "" then
+ save("en", 0)
+ if idx.en then
+ form.setValue(idx.en, false)
+ end
+ end
+ recompute()
+ updateNeeds()
+end
+
+-- Every sensor except date/time and GPS, as "Device / Label"; a saved sensor
+-- that isn't present is shown as " (not found)" (FR-022).
+local function buildSensorList()
+ sensorLabels, sensorIds, sensorPars, sensorUnits = { "(none)" }, { 0 }, { 0 }, { "" }
+ notFoundIdx = 0
+ local selected = 1
+ local parent = ""
+ for _, s in ipairs(system.getSensors()) do
+ if s.param == 0 then
+ parent = s.label
+ elseif s.type ~= 5 and s.type ~= 9 then
+ local n = #sensorLabels + 1
+ sensorLabels[n] = (parent ~= "") and (parent .. " / " .. s.label) or s.label
+ sensorIds[n], sensorPars[n], sensorUnits[n] = s.id, s.param, s.unit or ""
+ if s.id == cfg.sId and s.param == cfg.sPar then
+ selected = n
+ end
+ end
+ end
+ if cfg.sId ~= 0 and selected == 1 then
+ notFoundIdx = #sensorLabels + 1
+ sensorLabels[notFoundIdx] = cfg.sLbl .. " (not found)"
+ sensorIds[notFoundIdx], sensorPars[notFoundIdx], sensorUnits[notFoundIdx] = cfg.sId, cfg.sPar, ""
+ selected = notFoundIdx
+ end
+ return selected
+end
+
+-- Units that read as RPM (R9): "rpm", and "U/min" / "1/min" from German
+-- converters, ignoring case.
+local RPM_UNITS = { ["rpm"] = true, ["u/min"] = true, ["1/min"] = true }
+
+-- Row 7 (FR-010): warn when the chosen sensor's unit isn't RPM-like.
+local function updateUnitHint(i)
+ if idx.unit then
+ local u = sensorUnits[i] or ""
+ local bad = i ~= 1 and i ~= notFoundIdx and not RPM_UNITS[string.lower(u)]
+ form.setProperties(idx.unit, {
+ label = "Unit is '" .. u .. "', not RPM. Check the sensor, or set Sensor scale.",
+ visible = bad,
+ })
+ end
+end
+
+-- "= 31.5k" next to a threshold percentage.
+local function kText(rpm)
+ return string.format("= %.1fk RPM", rpm / 1000)
+end
+
+local function updateThresholdLabels()
+ if idx.armK then
+ form.setProperties(idx.armK, { label = kText(d.armRpm) })
+ end
+ if idx.flK then
+ form.setProperties(idx.flK, { label = kText(d.flRpm) })
+ end
+ if idx.maxHint then
+ local text = "0.0 = auto (4 x idle)"
+ if cfg.maxR == 0 and d.idleRpm > 0 then
+ text = text .. ": " .. string.format("%.1fk", d.fullRpm / 1000)
+ end
+ form.setProperties(idx.maxHint, { label = text })
+ end
+end
+
+-- Live RPM row (FR-010), from loop() while the form is open, every 500 ms.
+-- Reads the sensor itself: setup happens before monitoring is on.
+local function updateLive(now)
+ if not formOpen or not idx.live or now - st.liveAt < 500 then
+ return
+ end
+ st.liveAt = now
+ local text = TXT.noData
+ if cfg.sId ~= 0 then
+ local s = system.getSensorValueByID(cfg.sId, cfg.sPar)
+ if s ~= nil and s.valid then
+ text = fmtRpm(math.floor(s.value * d.kScale + 0.5))
+ end
+ end
+ form.setProperties(idx.live, { label = text })
+end
+
+-- An intbox for a setting stored in tenths or hundreds, shown with 1 decimal.
+local function addInt(label, key, step, after)
+ form.addRow(2)
+ form.addLabel({ label = label, width = LABEL_W })
+ return form.addIntbox(cfg[key], RANGES[key][1], RANGES[key][2], DEFAULTS[key] --[[@as integer]], 1, step, function(value)
+ save(key, value)
+ recompute()
+ if after then
+ after()
+ end
+ end)
+end
+
+local function addCheck(label, key)
+ form.addRow(2)
+ form.addLabel({ label = label, width = LABEL_W })
+ local i
+ i = form.addCheckbox(cfg[key] == 1, function(value)
+ local on = not value -- the callback gets the old state
+ if i then
+ form.setValue(i, on)
+ end
+ save(key, on and 1 or 0)
+ end)
+end
+
+local function initForm()
+ formOpen = true
+
+ heading(APP_NAME)
+ form.addRow(2)
+ form.addLabel({ label = "Monitoring", width = LABEL_W })
+ idx.en = form.addCheckbox(cfg.en == 1, function(value)
+ -- The callback gets the old state; toggle it (as in Speed Gauge).
+ local on = not value
+ if on and missingText() ~= "" then
+ on = false -- refused (FR-003); the hint says why
+ end
+ form.setValue(idx.en, on)
+ save("en", on and 1 or 0)
+ recompute()
+ updateNeeds()
+ end)
+ idx.needs = hint("", false)
+
+ if SIM then -- debug only (R12): pick a scripted RPM profile
+ form.addRow(2)
+ form.addLabel({ label = "SIM profile", width = 120 })
+ form.addSelectbox(sim.names, sim.idx, false, function(i)
+ sim.idx, sim.t0 = i, system.getTimeCounter()
+ onCut() -- start each profile disarmed and silent
+ end, { width = 200 })
+ end
+
+ heading("Engine")
+ form.addRow(2)
+ form.addLabel({ label = "RPM sensor", width = 120 })
+ local selected = buildSensorList()
+ form.addSelectbox(sensorLabels, selected, true, function(i)
+ save("sId", sensorIds[i])
+ save("sPar", sensorPars[i])
+ if i == 1 then
+ save("sLbl", "")
+ elseif i ~= notFoundIdx then
+ save("sLbl", string.sub(sensorLabels[i], 1, 63))
+ end
+ updateUnitHint(i)
+ onRequirementChanged()
+ end, { width = 200 })
+ hint("No telemetry yet. The ECU must send RPM and the receiver", #sensorLabels == 1)
+ hint("must be on. Some adapters take up to a minute.", #sensorLabels == 1)
+ idx.unit = hint("", false)
+ updateUnitHint(selected)
+
+ form.addRow(2)
+ form.addLabel({ label = "Sensor scale", width = LABEL_W })
+ form.addSelectbox({ "x1", "x10", "x100", "x1000" }, cfg.scl, false, function(i)
+ save("scl", i)
+ recompute()
+ st.liveAt = 0 -- show the rescaled value at once
+ end)
+
+ form.addRow(2)
+ form.addLabel({ label = "Live RPM", width = LABEL_W })
+ idx.live = form.addLabel({ label = TXT.noData, alignRight = true })
+
+ local idxMax
+ local function keepMax() -- max RPM must stay above idle (FR-031b)
+ if cfg.maxR ~= 0 and cfg.maxR <= cfg.idle then
+ save("maxR", 0)
+ recompute()
+ if idxMax then
+ form.setValue(idxMax, 0)
+ end
+ end
+ updateThresholdLabels()
+ end
+ form.addRow(2)
+ form.addLabel({ label = "Idle RPM (x1000)", width = LABEL_W })
+ form.addIntbox(cfg.idle, RANGES.idle[1], RANGES.idle[2], 0, 1, 1, function(value)
+ save("idle", value)
+ onRequirementChanged()
+ keepMax()
+ end)
+ hint("From the ECU setup. Check against Live RPM at idle.")
+
+ form.addRow(2)
+ form.addLabel({ label = "Max RPM (x1000)", width = LABEL_W })
+ idxMax = form.addIntbox(cfg.maxR, RANGES.maxR[1], RANGES.maxR[2], 0, 1, 1, function(value)
+ if value ~= 0 and value <= cfg.idle then
+ if idxMax then
+ form.setValue(idxMax, cfg.maxR) -- refused
+ end
+ form.setProperties(idx.maxHint, { label = "Max RPM must be above idle." })
+ return
+ end
+ save("maxR", value)
+ recompute()
+ updateThresholdLabels()
+ end)
+ idx.maxHint = hint("")
+
+ heading("Thresholds")
+ local idxArm, idxFl
+ local function refuse(box, old)
+ if box then
+ form.setValue(box, old)
+ end
+ form.setProperties(idx.thrHint, { visible = true })
+ end
+ form.addRow(2)
+ form.addLabel({ label = "Arming at % of idle", width = LABEL_W })
+ idxArm = form.addIntbox(cfg.armP, RANGES.armP[1], RANGES.armP[2], DEFAULTS.armP, 0, 1, function(value)
+ if cfg.flP >= value then
+ refuse(idxArm, cfg.armP)
+ return
+ end
+ save("armP", value)
+ recompute()
+ updateThresholdLabels()
+ end)
+ idx.armK = hint("")
+ form.addRow(2)
+ form.addLabel({ label = "Flameout below % of idle", width = LABEL_W })
+ idxFl = form.addIntbox(cfg.flP, RANGES.flP[1], RANGES.flP[2], DEFAULTS.flP, 0, 1, function(value)
+ if value >= cfg.armP then
+ refuse(idxFl, cfg.flP)
+ return
+ end
+ save("flP", value)
+ recompute()
+ updateThresholdLabels()
+ end)
+ idx.flK = hint("")
+ idx.thrHint = hint("Flameout must be below arming, and arming below idle.", false)
+
+ addInt("Arming time (s)", "armT", 5)
+ addInt("Detection delay (s)", "detT", 1)
+ addInt("Telemetry loss after (s)", "lossT", 5)
+
+ heading("Throttle cut")
+ form.addRow(2)
+ form.addLabel({ label = "Cut switch", width = LABEL_W })
+ form.addInputbox(cfg.swCut, false, function(v)
+ save("swCut", v)
+ onRequirementChanged()
+ end)
+ hint("Assign with the switch in the Cut (engine off) position.")
+
+ heading("Announcements")
+ addCheck("Say \"armed\"", "sayArm")
+ addCheck("Say \"relit\"", "sayRel")
+ form.addRow(2)
+ form.addLabel({ label = "Test alarm", width = LABEL_W })
+ form.addInputbox(cfg.swTest, false, function(v) save("swTest", v) end)
+ hint("Plays the alarm while on. Not while armed.")
+ hint("Voice files missing: alarm uses beeps.", not audio.ok)
+
+ form.addRow(1)
+ form.addLabel({ label = APP_NAME .. " " .. APP_VERSION .. ". Advisory only; does not replace the ECU's failsafe.",
+ font = FONT_MINI, alignRight = true })
+ form.addRow(1)
+ form.addLabel({ label = "Voice: Piper, Amy (CC BY-SA 4.0).", font = FONT_MINI, alignRight = true })
+
+ updateNeeds()
+ updateThresholdLabels()
+ st.liveAt = 0
+end
+
+-- The form is gone: drop its indices so nothing calls form.* on them (R10).
+local function closeForm()
+ formOpen = false
+ idx = {}
+end
+
+------------------------------------------------------------------------------
+-- Telemetry window (contracts/telemetry-window.md). The only place lcd is used.
+------------------------------------------------------------------------------
+
+-- Window display state (data-model.md).
+local function stateText()
+ if st.testOn then
+ return TXT.test
+ elseif st.state == S.OFF then
+ return TXT.off
+ elseif not st.sensorFound then
+ return TXT.noSensor
+ elseif st.state == S.FLAMEOUT then
+ return TXT.flameout
+ elseif st.lost then
+ return TXT.noTel
+ elseif st.state == S.ARMED then
+ return TXT.armed
+ end
+ return TXT.disarmed
+end
+
+-- Colors: Speed Gauge's palette (its research R8). Red only for FLAMEOUT
+-- (FR-031a).
+local C = {
+ bg = { 8, 10, 14 }, lit = { 0, 190, 255 }, unlit = { 34, 38, 44 }, edge = { 70, 78, 90 },
+ idle = { 255, 225, 0 }, num = { 255, 255, 255 }, label = { 170, 170, 170 }, alarm = { 255, 45, 30 },
+}
+
+-- Sweep geometry (design/mockup.py), in transmitter window coordinates
+-- (150 x 68). Iterate these on the transmitter (T046).
+local SW = { n = 12, x0 = 4, x1 = 146, gap = 2, base = 22, rise = 40, curve = 2.2, h0 = 6, dh = 12,
+ labelMaxW = 70 } -- widest bold state label that clears the sweep (y 2-19)
+SW.segW = (SW.x1 - SW.x0 - SW.gap * (SW.n - 1)) / SW.n
+
+-- Bottom edge and height of the sweep at fraction t (0 left .. 1 right).
+local function sweepAt(t)
+ return SW.base + SW.rise * (1 - t) ^ SW.curve, SW.h0 + SW.dh * t
+end
+
+local seg = {} -- per segment: 8 integers, x/y of four corners
+local marker = { x = 0, y = 0, h = 0, ver = -1 } -- idle marker, from recomputeMarker()
+local rend = nil ---@type Renderer|nil -- one renderer, reused (Speed Gauge, verified)
+local cpuWorst = 0 -- DEBUG_CPU only
+local isDs24 = false -- set in init(): the sweep needs a DS-24 II (R11)
+
+-- Segment corners, once in init(): no trig or allocation per frame (R11).
+local function buildSweep()
+ for i = 1, SW.n do
+ local xa = SW.x0 + (i - 1) * (SW.segW + SW.gap)
+ local xb = xa + SW.segW
+ local ya, ha = sweepAt((xa - SW.x0) / (SW.x1 - SW.x0))
+ local yb, hb = sweepAt((xb - SW.x0) / (SW.x1 - SW.x0))
+ seg[i] = {
+ math.floor(xa + 0.5), math.floor(ya + 0.5), math.floor(xb + 0.5), math.floor(yb + 0.5),
+ math.floor(xb + 0.5), math.floor(yb - hb + 0.5), math.floor(xa + 0.5), math.floor(ya - ha + 0.5),
+ }
+ end
+end
+
+-- Idle marker position. Recomputed when the settings change (d.ver), checked
+-- by the print function, so recompute() doesn't need to know about drawing.
+local function recomputeMarker()
+ if marker.ver == d.ver then
+ return
+ end
+ marker.ver = d.ver
+ local t = 0
+ if d.fullRpm > 0 then
+ t = math.min(1, d.idleRpm / d.fullRpm)
+ end
+ local y, h = sweepAt(t)
+ marker.x = math.floor(SW.x0 + t * (SW.x1 - SW.x0) + 0.5)
+ marker.y = math.floor(y - h + 0.5)
+ marker.h = math.floor(h + 0.5)
+end
+
+local function setColor(c)
+ lcd.setColor(c[1], c[2], c[3])
+end
+
+local function litCount()
+ if st.rpm == nil or st.rpm <= 0 then
+ return 0
+ end
+ return math.min(SW.n, math.ceil(SW.n * st.rpm / d.fullRpm))
+end
+
+-- State label color (contracts/telemetry-window.md).
+local function stateColor()
+ if st.state == S.ARMED and not st.lost and not st.testOn then
+ return C.lit
+ elseif st.state == S.OFF or (st.state == S.DISARMED and not st.testOn) then
+ return C.label
+ end
+ return C.num -- TEST, NO SENSOR, NO TELEMETRY
+end
+
+local function drawRight(text, x, y, font)
+ lcd.drawText(x - lcd.getTextWidth(font, text), y, text, font)
+end
+
+-- One segment's four corners into the renderer; close = repeat the first
+-- for an outline.
+local function addQuad(r, p, ox, close)
+ r:reset()
+ r:addPoint(ox + p[1], p[2])
+ r:addPoint(ox + p[3], p[4])
+ r:addPoint(ox + p[5], p[6])
+ r:addPoint(ox + p[7], p[8])
+ if close then
+ r:addPoint(ox + p[1], p[2])
+ end
+end
+
+-- Double size (150 x 68): the segmented sweep of design/rpm-window-mockup.svg.
+-- Opaque fills and thin opaque outlines only: the cheap kind of drawing (R11).
+local function drawDouble(r, ox)
+ local lit = litCount()
+ for i = 1, SW.n do
+ local p = seg[i]
+ addQuad(r, p, ox, false)
+ if i <= lit then
+ setColor(C.lit)
+ r:renderPolygon()
+ else
+ setColor(C.unlit)
+ r:renderPolygon()
+ addQuad(r, p, ox, true)
+ setColor(C.edge)
+ r:renderPolyline(1)
+ end
+ end
+ if d.idleRpm > 0 then
+ setColor(C.idle)
+ lcd.drawFilledRectangle(ox + marker.x - 1, marker.y - 3, 2, marker.h + 6)
+ end
+
+ if st.state == S.FLAMEOUT then
+ setColor(C.alarm)
+ lcd.drawFilledRectangle(ox + 4, 2, 142, 24)
+ setColor(C.num)
+ local tw = lcd.getTextWidth(FONT_BIG, TXT.flameout)
+ lcd.drawText(ox + 75 - tw // 2, 4, TXT.flameout, FONT_BIG)
+ else
+ -- Bold where it stays clear of the rising sweep (ARMED, DISARMED, OFF,
+ -- TEST); the long labels (NO TELEMETRY, NO SENSOR) would run into it,
+ -- so they stay small (user feedback 2026-10-06).
+ local text = stateText()
+ local font = FONT_BOLD
+ if lcd.getTextWidth(font, text) > SW.labelMaxW then
+ font = FONT_MINI
+ end
+ setColor(stateColor())
+ lcd.drawText(ox + 4, 2, text, font)
+ end
+ setColor(C.num)
+ drawRight(st.rpmText, ox + 146, 32, FONT_BIG)
+ setColor(C.label)
+ drawRight((st.state == S.FLAMEOUT and st.lost) and TXT.noTel or "RPM", ox + 146, 54, FONT_MINI)
+end
+
+-- Single size (150 x 23): straight bar, idle marker, number (FR-031c).
+-- Bar on top, state label under it, number on the right clear of the bar.
+local BAR = { x = 4, y = 2, w = 76, h = 7 }
+
+local function drawSingle(ox)
+ if st.state == S.FLAMEOUT then
+ setColor(C.alarm)
+ lcd.drawFilledRectangle(ox, 0, 150, 23)
+ setColor(C.num)
+ lcd.drawText(ox + 4, 3, TXT.flameout, FONT_NORMAL)
+ -- "---" without data: "NO TELEMETRY" doesn't fit beside FLAMEOUT here.
+ drawRight(st.rpmText, ox + 146, 3, FONT_NORMAL)
+ return
+ end
+ setColor(C.unlit)
+ lcd.drawFilledRectangle(ox + BAR.x, BAR.y, BAR.w, BAR.h)
+ local fill = 0
+ if st.rpm ~= nil and st.rpm > 0 then
+ fill = math.min(BAR.w, math.floor(BAR.w * st.rpm / d.fullRpm + 0.5))
+ end
+ if fill > 0 then
+ setColor(C.lit)
+ lcd.drawFilledRectangle(ox + BAR.x, BAR.y, fill, BAR.h)
+ end
+ if d.idleRpm > 0 then
+ setColor(C.idle)
+ local x = ox + BAR.x + math.floor(BAR.w * math.min(1, d.idleRpm / d.fullRpm) + 0.5)
+ lcd.drawFilledRectangle(x - 1, 0, 2, BAR.y + BAR.h + 2)
+ end
+ setColor(stateColor())
+ lcd.drawText(ox + 4, 11, stateText(), FONT_MINI)
+ setColor(C.num)
+ drawRight(st.rpmText, ox + 146, 3, FONT_NORMAL)
+end
+
+-- Not a DS-24 II: text only, theme colors (the alarm works everywhere).
+local function drawText(w)
+ local r, g, b = lcd.getFgColor()
+ lcd.setColor(r, g, b)
+ if st.state == S.FLAMEOUT then
+ lcd.drawText(4, 2, TXT.flameout, FONT_BIG) -- dominant (FR-031)
+ drawRight(st.lost and TXT.noTel or st.rpmText, w - 4, 24, FONT_MINI)
+ return
+ end
+ lcd.drawText(4, 2, stateText(), FONT_MINI)
+ drawRight(st.rpmText, w - 4, 2, FONT_NORMAL)
+end
+
+local function printRpm(w, h)
+ if not isDs24 then
+ drawText(w)
+ return
+ end
+ local r = rend
+ if not r then
+ r = lcd.renderer()
+ rend = r
+ end
+ recomputeMarker()
+ local ox = 0
+ if w == 157 then -- emulator: title inside the window (TITLE_H)
+ h = h - TITLE_H
+ ox = 3
+ end
+ setColor(C.bg)
+ lcd.drawFilledRectangle(0, 0, w, h)
+ if h >= 45 then
+ drawDouble(r, ox)
+ if DEBUG_CPU then
+ local cpu = system.getCPU()
+ cpuWorst = math.max(cpuWorst, cpu)
+ setColor(C.label)
+ lcd.drawText(ox + 4, 54, string.format("CPU %d/%d", cpu, cpuWorst), FONT_MINI)
+ end
+ else
+ drawSingle(ox)
+ end
+end
+
+------------------------------------------------------------------------------
+-- Lifecycle
+------------------------------------------------------------------------------
+
+local function init()
+ loadSettings()
+ recompute()
+ resetSession()
+ checkAudio() -- once: five file opens (R6)
+ buildSweep()
+ isDs24 = string.find(system.getDeviceType() or "", "24 II", 1, true) ~= nil
+
+ system.registerForm(1, MENU_APPS, APP_NAME, initForm, nil, nil, closeForm)
+ system.registerTelemetry(1, APP_NAME, 0, printRpm)
+end
+
+local function loop()
+ local now = system.getTimeCounter()
+ if now - st.lastTick < TICK_MS then
+ return
+ end
+ st.lastTick = now
+
+ stepTest(now) -- before the OFF check: tests work with monitoring off (R13)
+ updateLive(now) -- settings form's Live RPM row, also while OFF
+
+ -- Row 0: off or not fully configured (FR-012, FR-034).
+ if not d.active then
+ if st.state == S.FLAMEOUT then
+ stopAlarm()
+ end
+ st.state = S.OFF
+ st.rpm = nil
+ updateRpmText()
+ return
+ end
+ if st.state == S.OFF then -- just turned on
+ st.state = S.DISARMED
+ st.armSince, st.lowSince, st.invalidSince, st.lost = nil, nil, nil, false
+ end
+
+ -- Always call through system: Emulator Telemetry replaces it (R9).
+ local s
+ if SIM and sim.idx > 1 then
+ s = simRead(now)
+ else
+ s = system.getSensorValueByID(cfg.sId, cfg.sPar)
+ end
+ local valid = false
+ st.sensorFound = s ~= nil
+ st.rpm = nil
+ if s ~= nil and s.valid then
+ valid = true
+ st.rpm = s.value * d.kScale
+ end
+
+ -- Row 1: Cut wins over everything (FR-016). The RPM above is still read so
+ -- the window follows the spool-down.
+ if switchOn(cfg.swCut) then
+ onCut()
+ updateRpmText()
+ return
+ end
+
+ if not valid then
+ onInvalid(now) -- row 2
+ else
+ st.invalidSince, st.lost = nil, false
+ if st.state == S.DISARMED then
+ stepDisarmed(now)
+ elseif st.state == S.ARMED then
+ stepArmed(now)
+ elseif st.state == S.FLAMEOUT then
+ stepFlameout(now)
+ end
+ end
+ -- Valid or not: the alarm keeps cycling through a telemetry loss (FR-020a).
+ if st.state == S.FLAMEOUT then
+ stepCycle(now)
+ end
+ updateRpmText()
+end
+
+local function destroy()
+ if st.state == S.FLAMEOUT or st.testOn then
+ stopAlarm()
+ end
+end
+
+---@type JetiApp
+return {
+ init = init,
+ loop = loop,
+ destroy = destroy,
+ author = "Aaron George",
+ version = APP_VERSION,
+ name = APP_NAME,
+}
diff --git a/src/Apps/AG-FlmOt/CREDITS.txt b/src/Apps/AG-FlmOt/CREDITS.txt
new file mode 100644
index 0000000..444461c
--- /dev/null
+++ b/src/Apps/AG-FlmOt/CREDITS.txt
@@ -0,0 +1,22 @@
+Flameout Alarm (AG-FlmOt) - alarm sounds
+========================================
+
+The WAV files in this folder were generated by tools/voice/make_flameout.py
+in the jeti-lua-apps repository. They are licensed CC BY-SA 4.0 (below),
+not MIT like the rest of the repository.
+
+Speech synthesis: Piper, https://github.com/rhasspy/piper (MIT), as
+packaged by piper-tts, https://github.com/OHF-Voice/piper1-gpl
+(GPL-3.0-or-later). Only used to generate these files; no Piper code is
+included here.
+
+Voice: "Amy" (en_US-amy-medium), by Mycroft / Rhasspy,
+https://huggingface.co/rhasspy/piper-voices, licensed under
+Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0),
+https://creativecommons.org/licenses/by-sa/4.0/
+
+The lock tone is synthesized by the script and contains no voice.
+
+These generated files are distributed under CC BY-SA 4.0. The speech was
+sped up, pitch-shifted, equalized, compressed, trimmed of silence and
+peak-normalized.
diff --git a/src/Apps/AG-FlmOt/armed.wav b/src/Apps/AG-FlmOt/armed.wav
new file mode 100644
index 0000000..87f792e
Binary files /dev/null and b/src/Apps/AG-FlmOt/armed.wav differ
diff --git a/src/Apps/AG-FlmOt/cycle.wav b/src/Apps/AG-FlmOt/cycle.wav
new file mode 100644
index 0000000..9dc413d
Binary files /dev/null and b/src/Apps/AG-FlmOt/cycle.wav differ
diff --git a/src/Apps/AG-FlmOt/cycletl.wav b/src/Apps/AG-FlmOt/cycletl.wav
new file mode 100644
index 0000000..80ffc25
Binary files /dev/null and b/src/Apps/AG-FlmOt/cycletl.wav differ
diff --git a/src/Apps/AG-FlmOt/relit.wav b/src/Apps/AG-FlmOt/relit.wav
new file mode 100644
index 0000000..5139ea9
Binary files /dev/null and b/src/Apps/AG-FlmOt/relit.wav differ
diff --git a/src/Apps/AG-FlmOt/tlost.wav b/src/Apps/AG-FlmOt/tlost.wav
new file mode 100644
index 0000000..74a6b76
Binary files /dev/null and b/src/Apps/AG-FlmOt/tlost.wav differ
diff --git a/tools/emulator/sensors.json b/tools/emulator/sensors.json
index d1ddc29..f4b5b43 100644
--- a/tools/emulator/sensors.json
+++ b/tools/emulator/sensors.json
@@ -52,5 +52,26 @@
"lowerBound": 0,
"upperBound": 83.3,
"input": "P6"
+ },
+ {
+ "id": 3,
+ "param": 0,
+ "decimals": 0,
+ "type": 1,
+ "label": "Turbine",
+ "unit": "",
+ "sensorName": "Turbine"
+ },
+ {
+ "id": 3,
+ "param": 1,
+ "decimals": 0,
+ "type": 1,
+ "label": "RPM",
+ "unit": "rpm",
+ "sensorName": "Turbine",
+ "lowerBound": 0,
+ "upperBound": 160000,
+ "input": "P8"
}
]
diff --git a/tools/publish/make_icon.py b/tools/publish/make_icon.py
index f0a61fa..b46302b 100644
--- a/tools/publish/make_icon.py
+++ b/tools/publish/make_icon.py
@@ -1,15 +1,19 @@
-"""make_icon.py - draw Speed Gauge's app icon for JETI Studio.
+"""make_icon.py - draw the app icons for JETI Studio.
Copyright (c) 2026 Aaron George
SPDX-License-Identifier: MIT
-A 64 x 64 PNG (the size of JETI's own app icons) in the gauge's style: a
-dark face, the grey track, a cyan speed arc, the orange overspeed zone and
-a yellow max mark, with "AG" (Aaron George) in the center; smoothed by
-sampling each pixel 8 x 8.
+64 x 64 PNGs (the size of JETI's own app icons), smoothed by sampling each
+pixel 8 x 8, with "AG" (Aaron George) drawn as strokes:
- python tools/publish/make_icon.py # docs/apps/img/speed-gauge-icon.png
- python tools/publish/make_icon.py --preview # also all variants, enlarged
+- Speed Gauge: a dark face, the grey track, a cyan speed arc, the orange
+ overspeed zone and a yellow max mark, "AG" in the center.
+- Flameout Alarm (--app flameout): the window's rising segmented RPM sweep,
+ lit cyan up to past a yellow idle marker, "AG" at the upper left.
+
+ python tools/publish/make_icon.py # docs/apps/img/speed-gauge-icon.png
+ python tools/publish/make_icon.py --app flameout # docs/apps/img/flameout-alarm-icon.png
+ python tools/publish/make_icon.py --preview DIR # also Speed Gauge's variants
Standard library only.
"""
@@ -140,6 +144,52 @@ def shade(x, y, variant):
return col
+UNLIT = (34, 38, 44)
+EDGE = (70, 78, 90)
+
+# Flameout Alarm's sweep, scaled from the window (design/mockup.py): segments
+# from x0 to x1, bottom edge rising and flattening to the right, growing taller.
+FLM = {"n": 6, "x0": 7.0, "x1": 57.0, "gap": 2.5, "base": 37.0, "rise": 17.0,
+ "curve": 2.2, "h0": 8.0, "dh": 13.0, "lit": 3, "idle": 0.36,
+ "ag": (-10.0, -17.0)} # monogram offset from the tile center
+
+
+def shade_flameout(x, y):
+ """Color of one sample point in Flameout Alarm's icon."""
+ c = SIZE / 2
+ dx, dy = x - c, y - c
+ r_corner = 12
+ qx, qy = max(abs(dx) - (c - r_corner), 0), max(abs(dy) - (c - r_corner), 0)
+ if math.hypot(qx, qy) > r_corner:
+ return None
+ col = BG
+ n, x0, x1, gap = FLM["n"], FLM["x0"], FLM["x1"], FLM["gap"]
+ seg_w = (x1 - x0 - gap * (n - 1)) / n
+ if x0 <= x <= x1:
+ t = (x - x0) / (x1 - x0)
+ bottom = FLM["base"] + FLM["rise"] * (1 - t) ** FLM["curve"]
+ top = bottom - (FLM["h0"] + FLM["dh"] * t)
+ k = int((x - x0) // (seg_w + gap))
+ in_seg = (x - x0) - k * (seg_w + gap) <= seg_w
+ if in_seg and top <= y <= bottom:
+ if k < FLM["lit"]:
+ col = CYAN
+ else:
+ edge = min(y - top, bottom - y) < 0.8 or \
+ min((x - x0) - k * (seg_w + gap), seg_w - ((x - x0) - k * (seg_w + gap))) < 0.8
+ col = EDGE if edge else UNLIT
+ # Idle marker: a yellow bar across the sweep.
+ xi = x0 + FLM["idle"] * (x1 - x0)
+ bi = FLM["base"] + FLM["rise"] * (1 - FLM["idle"]) ** FLM["curve"]
+ ti = bi - (FLM["h0"] + FLM["dh"] * FLM["idle"])
+ if abs(x - xi) <= 1.1 and ti - 3 <= y <= bi + 3:
+ col = YELLOW
+ ox, oy = FLM["ag"]
+ if monogram(x - ox, y - oy, c):
+ col = WHITE
+ return col
+
+
def render(variant, size=SIZE):
rows = []
step = 1 / SS
@@ -150,8 +200,12 @@ def render(variant, size=SIZE):
acc = [0.0, 0.0, 0.0, 0.0]
for sy in range(SS):
for sx in range(SS):
- col = shade((px + (sx + 0.5) * step) * scale,
- (py + (sy + 0.5) * step) * scale, variant)
+ sxp = (px + (sx + 0.5) * step) * scale
+ syp = (py + (sy + 0.5) * step) * scale
+ if variant == "flameout":
+ col = shade_flameout(sxp, syp)
+ else:
+ col = shade(sxp, syp, variant)
if col is not None:
acc[0] += col[0]
acc[1] += col[1]
@@ -182,6 +236,11 @@ def chunk(kind, data):
def main():
+ if "--app" in sys.argv and sys.argv[sys.argv.index("--app") + 1] == "flameout":
+ out = REPO / "docs" / "apps" / "img" / "flameout-alarm-icon.png"
+ write_png(out, SIZE, render("flameout"))
+ print(f"wrote {out.relative_to(REPO)}")
+ return
write_png(OUT, SIZE, render("arc"))
print(f"wrote {OUT.relative_to(REPO)}")
if "--preview" in sys.argv:
diff --git a/tools/voice/README.md b/tools/voice/README.md
index da74123..54fa0a3 100644
--- a/tools/voice/README.md
+++ b/tools/voice/README.md
@@ -67,12 +67,44 @@ The normal deploy copies the voice with the app's asset folder (see
Copy-Item src\Apps\AG-SpdGa $dst -Recurse -Force
```
+## Flameout Alarm sounds
+
+`make_flameout.py` generates Flameout Alarm's sounds (spec 003, FR-026 to
+FR-028; research R5) into `src/Apps/AG-FlmOt/`, using the same Amy model
+and this script's helpers (it doesn't change `make_voice.py`):
+
+| File | Content |
+| --- | --- |
+| `cycle.wav` | Urgent "Flameout!" ×3, then the lock tone; exactly 5.000 s (one alarm cycle) |
+| `cycletl.wav` | The same with "Engine telemetry lost" after the callout; exactly 5.000 s |
+| `tlost.wav` | "Engine telemetry lost" |
+| `armed.wav` | "Flameout alarm armed" |
+| `relit.wav` | "Engine relit" |
+
+```powershell
+python tools/voice/make_flameout.py --model C:\path\to\en_US-amy-medium.onnx
+```
+
+Options: `--out` (default `src/Apps/AG-FlmOt`) and `--rate` (default 22050).
+
+The urgent callout is made in Python, without ffmpeg: Piper length scale
+0.749 (0.70 × 1.07), noise scale 0.5, then resampled so it plays 7% faster
+and about one semitone higher, a 150 Hz high-pass, +4 dB at 3 kHz and
+compression (−18 dB threshold, ratio 4, 3 ms attack, 60 ms release, +4 dB
+makeup). The other phrases use Speed Gauge's normal pace (1.5). The lock
+tone is 1800 Hz plus a 3600 Hz overtone, 45 ms on and 35 ms off.
+
+The self-check exits 1 if a file is missing, a cycle file isn't exactly
+5.000 s, the callout runs over 2.5 s, or the voice part of `cycletl.wav`
+runs over 4.0 s. The files are committed, under CC BY-SA 4.0.
+
## Licensing
- **Voice model:** "Amy" (`en_US-amy-medium`) by Mycroft / Rhasspy is
licensed CC BY-SA 4.0, and the license of the recordings it was trained on
- is undocumented. The committed set in `src/Apps/AG-SpdGa/voice/` is
- therefore CC BY-SA 4.0, not MIT; keep its `CREDITS.txt` with it.
+ is undocumented. The committed sets in `src/Apps/AG-SpdGa/voice/` and
+ `src/Apps/AG-FlmOt/` are therefore CC BY-SA 4.0, not MIT; keep their
+ `CREDITS.txt` with them.
- **Piper:** the `piper-tts` package is GPL-3.0-or-later
([OHF-Voice/piper1-gpl](https://github.com/OHF-Voice/piper1-gpl)),
continuing the original MIT-licensed
diff --git a/tools/voice/make_flameout.py b/tools/voice/make_flameout.py
new file mode 100644
index 0000000..d5392a3
--- /dev/null
+++ b/tools/voice/make_flameout.py
@@ -0,0 +1,252 @@
+"""make_flameout.py - generate Flameout Alarm's sounds (Piper "Amy").
+
+Copyright (c) 2026 Aaron George
+SPDX-License-Identifier: MIT
+
+Writes five WAV files plus CREDITS.txt to src/Apps/AG-FlmOt/ (spec 003,
+FR-026 to FR-028; research R5 in specs/003-flameout-alarm/research.md):
+
+ cycle.wav urgent "Flameout!" x3, then the lock tone, exactly 5.000 s
+ cycletl.wav the same with "Engine telemetry lost" after the callout
+ tlost.wav "Engine telemetry lost"
+ armed.wav "Flameout alarm armed"
+ relit.wav "Engine relit"
+
+ python tools/voice/make_flameout.py --model /en_US-amy-medium.onnx
+
+Reuses make_voice.py's helpers (Speed Gauge's generator) without changing
+it. Exits 1 if a file is missing or a length check fails.
+"""
+
+import argparse
+import math
+import sys
+import wave
+from array import array
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from make_voice import (PEAK, normalize, resample, synthesize, trim, # noqa: E402
+ write_text, write_wav)
+
+REPO = Path(__file__).resolve().parents[2]
+DEFAULT_OUT = REPO / "src" / "Apps" / "AG-FlmOt"
+
+CYCLE_S = 5.0 # one alarm cycle, callout start to callout start (FR-023)
+CALLOUT_MAX_S = 2.5 # "about 2 s" (FR-023)
+CYCLETL_VOICE_MAX_S = 4.0 # leaves at least 1 s of lock tone (R5)
+REPEAT_GAP_S = 0.12 # between the three "Flameout!"
+TL_GAP_S = 0.15 # between the callout and "Engine telemetry lost"
+
+# Urgent delivery (R5): the draft's Piper length scale 0.70, then ffmpeg
+# asetrate x1.07 + atempo 0.9346. Same result without a time-stretch:
+# synthesize 7% slower, then play 7% faster (pitch up about a semitone).
+PITCH = 1.07
+URGENT_LENGTH = 0.70 * PITCH
+URGENT_NOISE = 0.5
+HPF_HZ = 150.0
+PRESENCE_HZ, PRESENCE_DB, PRESENCE_Q = 3000.0, 4.0, 1.0
+COMP_THRESH_DB, COMP_RATIO = -18.0, 4.0
+COMP_ATTACK_S, COMP_RELEASE_S, COMP_MAKEUP_DB = 0.003, 0.060, 4.0
+
+CALM_SPEED = 1.5 # Speed Gauge's pace, for the non-alarm phrases
+
+# Lock tone, from the approved sample (2026-10-02, FR-027).
+TONE_HZ, OVERTONE_HZ, OVERTONE_LEVEL = 1800.0, 3600.0, 0.3
+BEEP_ON_S, BEEP_OFF_S, BEEP_FADE_S = 0.045, 0.035, 0.003
+
+CALM = [("tlost", "Engine telemetry lost"),
+ ("armed", "Flameout alarm armed"),
+ ("relit", "Engine relit")]
+FILES = ["cycle", "cycletl"] + [stem for stem, _ in CALM]
+
+CREDITS = """Flameout Alarm (AG-FlmOt) - alarm sounds
+========================================
+
+The WAV files in this folder were generated by tools/voice/make_flameout.py
+in the jeti-lua-apps repository. They are licensed CC BY-SA 4.0 (below),
+not MIT like the rest of the repository.
+
+Speech synthesis: Piper, https://github.com/rhasspy/piper (MIT), as
+packaged by piper-tts, https://github.com/OHF-Voice/piper1-gpl
+(GPL-3.0-or-later). Only used to generate these files; no Piper code is
+included here.
+
+Voice: "Amy" (en_US-amy-medium), by Mycroft / Rhasspy,
+https://huggingface.co/rhasspy/piper-voices, licensed under
+Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0),
+https://creativecommons.org/licenses/by-sa/4.0/
+
+The lock tone is synthesized by the script and contains no voice.
+
+These generated files are distributed under CC BY-SA 4.0. The speech was
+sped up, pitch-shifted, equalized, compressed, trimmed of silence and
+peak-normalized.
+"""
+
+
+# --- Filters (RBJ audio EQ cookbook) ----------------------------------------
+
+def biquad(x: list, b0, b1, b2, a0, a1, a2) -> list:
+ b0, b1, b2, a1, a2 = b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0
+ y = [0.0] * len(x)
+ x1 = x2 = y1 = y2 = 0.0
+ for i, v in enumerate(x):
+ out = b0 * v + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2
+ x2, x1, y2, y1 = x1, v, y1, out
+ y[i] = out
+ return y
+
+
+def high_pass(x: list, rate: int, f0: float, q: float = 0.7071) -> list:
+ w0 = 2 * math.pi * f0 / rate
+ c, alpha = math.cos(w0), math.sin(w0) / (2 * q)
+ return biquad(x, (1 + c) / 2, -(1 + c), (1 + c) / 2, 1 + alpha, -2 * c, 1 - alpha)
+
+
+def peaking(x: list, rate: int, f0: float, gain_db: float, q: float) -> list:
+ a = 10 ** (gain_db / 40)
+ w0 = 2 * math.pi * f0 / rate
+ c, alpha = math.cos(w0), math.sin(w0) / (2 * q)
+ return biquad(x, 1 + alpha * a, -2 * c, 1 - alpha * a, 1 + alpha / a, -2 * c, 1 - alpha / a)
+
+
+def compress(x: list, rate: int) -> list:
+ """Peak envelope follower, hard knee."""
+ att = math.exp(-1 / (COMP_ATTACK_S * rate))
+ rel = math.exp(-1 / (COMP_RELEASE_S * rate))
+ env, out = 0.0, [0.0] * len(x)
+ for i, v in enumerate(x):
+ a = abs(v)
+ k = att if a > env else rel
+ env = k * env + (1 - k) * a
+ level = 20 * math.log10(max(env, 1e-9) / 32768)
+ over = level - COMP_THRESH_DB
+ gain_db = COMP_MAKEUP_DB - (over * (1 - 1 / COMP_RATIO) if over > 0 else 0)
+ out[i] = v * 10 ** (gain_db / 20)
+ return out
+
+
+def to_int16(x: list) -> array:
+ return array("h", (max(-32768, min(32767, round(v))) for v in x))
+
+
+# --- Sounds --------------------------------------------------------------------
+
+def silence(seconds: float, rate: int) -> array:
+ return array("h", bytes(2 * round(seconds * rate)))
+
+
+def urgent(voice, cfg_cls, text: str, out_rate: int) -> array:
+ samples, rate = synthesize(voice, cfg_cls(length_scale=URGENT_LENGTH,
+ noise_scale=URGENT_NOISE), text)
+ samples = resample(samples, round(rate * PITCH), rate) # asetrate x1.07
+ x = [float(s) for s in samples]
+ x = high_pass(x, rate, HPF_HZ)
+ x = peaking(x, rate, PRESENCE_HZ, PRESENCE_DB, PRESENCE_Q)
+ x = compress(x, rate)
+ return resample(normalize(trim(to_int16(x), rate)), rate, out_rate)
+
+
+def calm(voice, cfg_cls, text: str, out_rate: int) -> array:
+ samples, rate = synthesize(voice, cfg_cls(length_scale=1.0 / CALM_SPEED), text)
+ return resample(normalize(trim(samples, rate)), rate, out_rate)
+
+
+def lock_tone(n: int, rate: int) -> array:
+ """n samples of the beep pattern, faded out at the end if cut mid-beep."""
+ on, period = round(BEEP_ON_S * rate), round((BEEP_ON_S + BEEP_OFF_S) * rate)
+ fade = max(1, round(BEEP_FADE_S * rate))
+ scale = PEAK / (1 + OVERTONE_LEVEL)
+ out = array("h", bytes(2 * n))
+ for i in range(n):
+ j = i % period
+ if j >= on:
+ continue
+ t = j / rate
+ env = min(1.0, j / fade, (on - j) / fade, (n - i) / fade)
+ v = math.sin(2 * math.pi * TONE_HZ * t) + OVERTONE_LEVEL * math.sin(2 * math.pi * OVERTONE_HZ * t)
+ out[i] = round(scale * env * v)
+ return out
+
+
+def cycle(voice_part: array, rate: int) -> array:
+ total = round(CYCLE_S * rate)
+ if len(voice_part) >= total:
+ raise RuntimeError("voice part is longer than the cycle")
+ return voice_part + lock_tone(total - len(voice_part), rate)
+
+
+# --- Check -----------------------------------------------------------------------
+
+def duration(path: Path) -> float:
+ with wave.open(str(path), "rb") as r:
+ return r.getnframes() / r.getframerate()
+
+
+def self_check(out: Path, rate: int, callout_s: float, tl_voice_s: float) -> int:
+ ok = True
+ missing = [f for f in FILES if not (out / f"{f}.wav").is_file()]
+ if missing:
+ print(f"FAIL: missing {', '.join(missing)}")
+ return 1
+ for f in ("cycle", "cycletl"):
+ with wave.open(str(out / f"{f}.wav"), "rb") as r:
+ n = r.getnframes()
+ if n != round(CYCLE_S * rate):
+ print(f"FAIL: {f}.wav is {n} samples, expected {round(CYCLE_S * rate)}")
+ ok = False
+ print(f"Callout: {callout_s:.2f} s (max {CALLOUT_MAX_S}). "
+ f"cycletl voice: {tl_voice_s:.2f} s (max {CYCLETL_VOICE_MAX_S}).")
+ if callout_s > CALLOUT_MAX_S or tl_voice_s > CYCLETL_VOICE_MAX_S:
+ print("FAIL: voice part too long for the 5-second cycle")
+ ok = False
+ for stem, _ in CALM:
+ print(f"{stem}.wav: {duration(out / f'{stem}.wav'):.2f} s")
+ return 0 if ok else 1
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description="Generate Flameout Alarm's sounds.")
+ ap.add_argument("--model", required=True, type=Path,
+ help="path to en_US-amy-medium.onnx (its .onnx.json next to it)")
+ ap.add_argument("--out", type=Path, default=DEFAULT_OUT,
+ help="output folder (default: src/Apps/AG-FlmOt)")
+ ap.add_argument("--rate", type=int, choices=(16000, 22050, 44100), default=22050,
+ help="output sample rate (default 22050, Amy's own)")
+ args = ap.parse_args()
+
+ if not args.model.is_file():
+ print(f"Model not found: {args.model}")
+ return 1
+ try:
+ from piper import PiperVoice
+ try:
+ from piper import SynthesisConfig
+ except ImportError:
+ from piper.config import SynthesisConfig
+ except ImportError:
+ print("piper-tts is not installed: python -m pip install -r tools/voice/requirements.txt")
+ return 1
+
+ out = args.out.resolve()
+ out.mkdir(parents=True, exist_ok=True)
+ rate = args.rate
+ voice = PiperVoice.load(str(args.model))
+
+ word = urgent(voice, SynthesisConfig, "Flameout!", rate)
+ gap = silence(REPEAT_GAP_S, rate)
+ callout = word + gap + word + gap + word
+ phrases = {stem: calm(voice, SynthesisConfig, text, rate) for stem, text in CALM}
+ tl_voice = callout + silence(TL_GAP_S, rate) + phrases["tlost"]
+
+ write_wav(out / "cycle.wav", cycle(callout, rate), rate)
+ write_wav(out / "cycletl.wav", cycle(tl_voice, rate), rate)
+ for stem, samples in phrases.items():
+ write_wav(out / f"{stem}.wav", samples, rate)
+ write_text(out / "CREDITS.txt", CREDITS)
+ return self_check(out, rate, len(callout) / rate, len(tl_voice) / rate)
+
+
+if __name__ == "__main__":
+ sys.exit(main())