Skill: ironystock/autonoannounce (version 0.2.5)

--- name: autonoannounce description: Build, operate, and troubleshoot Autonoannounce local speaker text-to-speech using the queued pipeline (enqueue to worker to ElevenLabs to playback backend). Requires ELEVENLABS_API_KEY for network synthesis, with optional ELEVENLABS_VOICE_ID and ELEVENLABS_MODEL_ID for voice/model selection. Writes local runtime state to config/tts-queue.json, .openclaw/*, and audio/earcons/*. Uses https://api.elevenlabs.io for synthesis/preflight calls. Use when creating or improving low-latency fire-and-forget TTS flows, tuning burst behavior, validating queue performance, enforcing local-only speech policy, or debugging queue/worker playback failures. metadata: {"openclaw":{"skillKey":"autonoannounce","homepage":"https://github.com/ironystock/autonoannounce","requires":{"env":["ELEVENLABS_API_KEY"],"bins":["python3","curl"],"anyBins":["mpv","ffplay","afplay","paplay","powershell"]},"primaryEnv":"ELEVENLABS_API_KEY"}} --- # Local TTS Queue ## Requirements - Required credential: `ELEVENLABS_API_KEY` - Recommended env vars: `ELEVENLABS_VOICE_ID`, `ELEVENLABS_MODEL_ID` - Runtime tools: `python3`, `curl`, and one local playback backend (`mpv`, `ffplay`, `afplay`, `paplay`, or PowerShell sound player) - Network destination for synthesis/preflight: `https://api.elevenlabs.io` ## Overview Use this skill to keep local speech fast, reliable, and policy-compliant by treating enqueue as fire-and-forget and isolating synthesis/playback inside the queue worker. ## Quick start workflow 1. Confirm queue health with `scripts/tts-queue-status.sh`. 2. Enqueue speech with `scripts/speak-local-queued.sh "text"`. 3. If audio does not play, inspect worker logs and runbook steps in `references/runbook.md`. 4. For latency tuning, run `scripts/benchmark-autonoannounce.sh` and compare against SLOs in `references/perf-slos.md`. ## Operating rules - Keep the producer path non-blocking: enqueue then return immediately. - Keep synthesis/playback in worker-only execution paths. - Prefer fewer larger writes to the queue (coalesce bursty traffic when possible). - Use policy-safe output lanes: local speaker for protected users; no Discord voice-file fallback. - Treat one failed item as isolated: retry with bounds, then dead-letter; do not stall entire queue. ## Commands - Enqueue: `scripts/speak-local-queued.sh "<text>"` - Worker (foreground): `scripts/tts-queue-worker.sh` - Worker daemon: `scripts/tts-queue-daemon.sh` - Status: `scripts/tts-queue-status.sh` - Benchmark harness: `scripts/benchmark-autonoannounce.sh` - Fast foreground benchmark: `scripts/benchmark-autonoannounce.sh 5` - Full diagnostic benchmark: `scripts/benchmark-autonoannounce.sh 5 --status both --output full` - First-run interactive setup: `skills/autonoannounce/scripts/setup-first-run.sh` (shell wrapper) - Cross-platform first-run CLI: `skills/autonoannounce/scripts/setup_first_run.py` - Backend detection (OS-aware): `skills/autonoannounce/scripts/backend-detect.sh` - ElevenLabs capability preflight: `skills/autonoannounce/scripts/elevenlabs-preflight.sh` (includes short 429 retry/backoff for SFX probe) - Earcon library manager (durable categories/cache): `skills/autonoannounce/scripts/earcon-library.sh` - Cross-platform playback runner: `skills/autonoannounce/scripts/play-local-audio.sh` - Playback backend/device probe: `skills/autonoannounce/scripts/playback-probe.sh` - Playback backend startup validator: `skills/autonoannounce/scripts/playback-validate.sh` - Playback confirmation tone: `skills/autonoannounce/scripts/playback-test.sh` - v0.2 smoke tests: `skills/autonoannounce/scripts/test-v0.2.sh` - Race/concurrency stress checks: `skills/autonoannounce/scripts/race-stress.sh` ## References map - Runbook: `references/runbook.md` - Config contract: `references/config-contract.md` - Performance SLOs and interpretation: `references/perf-slos.md` - Foreground-latency optimization: `references/front-path-optimization.md` - Durable earcon categories and cache: `references/earcon-library.md` ## Execution checklist - Verify prerequisites (`ELEVENLABS_API_KEY`, `ELEVENLABS_VOICE_ID`, `mpv`). - Validate queue paths and lock behavior before tuning performance. - Measure baseline before making queue/worker changes. - Re-run benchmark after each material change. - Record final p50/p95 latency and queue-wait deltas in the task summary.

path: references/config-contract.md
content: # Local TTS Queue Config Contract

Primary config file (current environment):
- `config/tts-queue.json`

## Required keys
- `queuePath`: JSONL queue file path
- `lockPath`: lock file path
- `logPath`: worker log path

## Recommended keys
- `earconStartPath`: start-tone audio path
- `earconEndPath`: end-tone audio path
- `earconMode`: `off|once|burst|per-job` (prefer `off` or `burst` for low latency)
- `coalesceWindowMs`: debounce window for burst merge
- `maxRetries`: retry bound per queue item
- `deadLetterPath`: failed item sink
- `ttlMs`: drop items older than this age
- `priority`: lane selector (`interactive|background`)

## Behavioral guarantees
- Producer does append-only enqueue and returns immediately.
- Worker serializes dequeue ownership with lock.
- Worker handles synth+playback; producer never blocks on playback.
- Failed queue entries do not block later entries.

## Backward compatibility
- Additive key changes are preferred.
- Keep defaults safe for missing optional keys.
- Reject malformed config with clear startup error.


path: references/earcon-library.md
content: # Earcon Library (Durable)

Use `scripts/earcon-library.sh` to manage persistent earcons by category.

## Categories
- `start`
- `end`
- `update`
- `important`
- `error`

## Commands
```bash
# Initialize library file
skills/autonoannounce/scripts/earcon-library.sh init

# List current category mappings
skills/autonoannounce/scripts/earcon-library.sh list

# Show categories without assigned files
skills/autonoannounce/scripts/earcon-library.sh missing

# Generate/update one category via ElevenLabs SFX
skills/autonoannounce/scripts/earcon-library.sh generate important "arena horn with reverb" 2
```

## Durability behavior
- Generated files are stored under `audio/earcons/`.
- Metadata is stored in `earcons.libraryPath` (default `.openclaw/earcon-library.json`).
- Category mappings are written into `config/tts-queue.json` and reused.
- No per-message regeneration is required once categories are populated.


path: references/front-path-optimization.md
content: # Foreground Path Optimization

Goal: minimize user-visible turn time while preserving async playback behavior.

## Primary rule
Keep foreground work limited to enqueue + immediate acknowledgement.

## Common foreground delays
- Heavy health/status probes (`systemctl show`, full queue introspection)
- Verbose benchmark output and extra shell process startup
- Synchronous waits for queue drain or playback completion
- Extra post-processing not required for the immediate reply

## Recommended mitigations
1. Default benchmark mode to `--status none --output compact`.
2. Run deep status probes only on demand (`--status both --output full`).
3. Separate "quick ack" from "detailed analysis" into two turns when needed.
4. Avoid per-item logging in foreground unless actively debugging.
5. Keep producer scripts append-only and lock-light.

## Operational pattern
- Fast path (default): enqueue, brief text reply.
- Slow path (debug): explicit diagnostics command + structured breakdown.

## Acceptance checks
- Typical benchmark turn should complete in roughly 1-2s when status probes are disabled.
- Enqueue path should remain sub-100ms on local host under light load.
- Playback must continue asynchronously in worker after reply is sent.


path: references/perf-slos.md
content: # Local TTS Queue Performance SLOs

## Targets
- Enqueue latency (producer):
  - p50 <= 20 ms
  - p95 <= 50 ms
- Queue wait under light load:
  - p50 <= 500 ms
- Synth API latency (single request baseline):
  - p50 <= 600 ms
- Burst behavior (3 quick jobs):
  - queue wait should not grow linearly with full playback time for each item

## Measurement method
Use:
```bash
skills/autonoannounce/scripts/benchmark-autonoannounce.sh
```

Collect:
- enqueue timings
- optional direct synth timings
- worker-derived queue wait and end-to-end timings (if available in logs)

## Interpretation
- Fast enqueue + slow end-to-end usually indicates worker/playback bottleneck.
- Linear queue-wait growth indicates strict serial playback pressure; mitigate with coalescing, earcon tuning, and stale-item drops.
- API latency spikes with stable local timings indicate upstream service variance.


path: references/runbook.md
content: # Local TTS Queue Runbook

## 1) Validate prerequisites
- Environment:
  - `ELEVENLABS_API_KEY`
  - `ELEVENLABS_VOICE_ID` (recommended)
- Runtime:
  - local playback backend available (`mpv`, `afplay`, etc.)

Quick checks:
```bash
printenv ELEVENLABS_API_KEY | wc -c
printenv ELEVENLABS_VOICE_ID | wc -c
skills/autonoannounce/scripts/playback-probe.sh auto
skills/autonoannounce/scripts/playback-validate.sh
skills/autonoannounce/scripts/playback-test.sh
```

## 2) Run capability preflight (ElevenLabs + SFX)
```bash
skills/autonoannounce/scripts/elevenlabs-preflight.sh
```
Optional retry tuning:
```bash
SFX_MAX_RETRIES=3 SFX_BASE_DELAY_MS=250 SFX_MAX_DELAY_MS=2000 \
  skills/autonoannounce/scripts/elevenlabs-preflight.sh
```
Interpretation:
- `sfx_status="ok"` → SFX generation available now.
- `sfx_status="rate_limited"` → retries exhausted under 429 pressure; fallback to cached/local earcons.
- `sfx_status="forbidden_or_missing_permission"` → key lacks permission (or access denied).
- `sfx_status="upstream_error"` → transient server-side fault; keep local fallback and retry later.


## 3) Validate queue plumbing
```bash
scripts/tts-queue-status.sh
```
Confirm queue file, lock file, and log path exist and are writable.

## 4) Smoke test end-to-end
```bash
scripts/speak-local-queued.sh "Queue smoke test"
```
Then monitor worker log for dequeue/synth/playback completion.

## 5) Worker/startup failure triage
Common failures:
- Missing env vars: worker cannot synthesize
- Invalid/missing playback backend: synth may succeed but local playback fails
- Queue lock stale: queue appears stuck

Actions:
1. Run `skills/autonoannounce/scripts/playback-validate.sh`.
2. Fix missing dependency or choose a different backend in setup/config.
3. Restart daemon/worker.
4. Re-enqueue one test item.
5. Confirm queue drains.

## 6) Playback contract
Use `skills/autonoannounce/scripts/play-local-audio.sh <file>` as the unified local player interface.

Contract:
- Input: readable local audio file path.
- Backend resolution order: explicit flag -> config playback.backend -> auto-detect.
- Output: zero exit on successful playback, non-zero with reason on failure.

## 7) Burst latency triage
If queue wait ramps quickly during bursts:
- Reduce or disable earcons in worker critical path.
- Enable/adjust burst coalescing window.
- Add stale-item TTL for superseded chatter.
- Consider prefetching next synth while current audio plays.

## 8) Concurrency/race stress check
Run:
```bash
skills/autonoannounce/scripts/race-stress.sh
```
Expected output includes:
- `config_json_ok`
- `metadata_race_ok`
- `race_stress_ok`

## 9) Policy checks
For protected users (Brad/RECTANGL):
- Use local speaker path only.
- Do not emit Discord TTS attachments as fallback.


path: scripts/backend-detect.sh
content: #!/usr/bin/env bash
set -euo pipefail

os=$(uname -s 2>/dev/null || echo unknown)

check_bin() { command -v "$1" >/dev/null 2>&1; }

if [[ "$os" == "Linux" ]]; then
  for b in mpv ffplay paplay; do
    if check_bin "$b"; then echo "$b"; exit 0; fi
  done
elif [[ "$os" == "Darwin" ]]; then
  for b in afplay mpv ffplay; do
    if check_bin "$b"; then echo "$b"; exit 0; fi
  done
elif [[ "$os" =~ MINGW|MSYS|CYGWIN|Windows_NT ]]; then
  for b in mpv ffplay; do
    if check_bin "$b"; then echo "$b"; exit 0; fi
  done
  echo "powershell-soundplayer"; exit 0
fi

echo "none"
exit 1


path: scripts/benchmark-autonoannounce.sh
content: #!/usr/bin/env bash
set -euo pipefail

ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
ENQUEUE="$ROOT/scripts/speak-local-queued.sh"
STATUS="$ROOT/scripts/tts-queue-status.sh"

ITERATIONS="5"
STATUS_MODE="none"   # none|pre|post|both
OUTPUT_MODE="compact" # compact|full

usage() {
  cat <<'EOF'
Usage: benchmark-autonoannounce.sh [iterations] [--status none|pre|post|both] [--output compact|full]

Examples:
  benchmark-autonoannounce.sh 5
  benchmark-autonoannounce.sh 5 --status both --output full

Defaults are optimized for foreground speed:
  --status none
  --output compact
EOF
}

if [[ "${1:-}" =~ ^[0-9]+$ ]]; then
  ITERATIONS="$1"
  shift
fi

while [[ $# -gt 0 ]]; do
  case "$1" in
    --status)
      STATUS_MODE="${2:-none}"
      shift 2
      ;;
    --output)
      OUTPUT_MODE="${2:-compact}"
      shift 2
      ;;
    -h|--help)
      usage
      exit 0
      ;;
    *)
      echo "Unknown argument: $1" >&2
      usage >&2
      exit 2
      ;;
  esac
done

TEXT_PREFIX="benchmark-$(date +%s)"

if [[ "$OUTPUT_MODE" == "full" ]]; then
  echo "[bench] autonoannounce iterations=$ITERATIONS status=$STATUS_MODE output=$OUTPUT_MODE"
fi

run_status() {
  local phase="$1"
  if [[ "$STATUS_MODE" == "$phase" || "$STATUS_MODE" == "both" ]]; then
    [[ "$OUTPUT_MODE" == "full" ]] && echo "[bench] queue status ($phase)"
    "$STATUS" || true
  fi
}

enqueue_one() {
  local text="$1"
  local start_ns end_ns dur_ms
  start_ns=$(date +%s%N)
  "$ENQUEUE" "$text" >/dev/null
  end_ns=$(date +%s%N)
  dur_ms=$(( (end_ns - start_ns) / 1000000 ))
  echo "$dur_ms"
}

run_status pre

sum=0
min=999999
max=0
for i in $(seq 1 "$ITERATIONS"); do
  ms=$(enqueue_one "$TEXT_PREFIX item-$i")
  sum=$((sum + ms))
  (( ms < min )) && min=$ms
  (( ms > max )) && max=$ms
  if [[ "$OUTPUT_MODE" == "full" ]]; then
    echo "[bench] enqueue[$i]=${ms}ms"
  fi
done

avg=$((sum / ITERATIONS))
run_status post

if [[ "$OUTPUT_MODE" == "compact" ]]; then
  echo "enqueue_ms: min=$min avg=$avg max=$max n=$ITERATIONS"
else
  echo "[bench] enqueue_min=${min}ms"
  echo "[bench] enqueue_avg=${avg}ms"
  echo "[bench] enqueue_max=${max}ms"
  echo "[bench] done"
fi


path: scripts/earcon-library.sh
content: #!/usr/bin/env bash
set -euo pipefail

ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
CFG="$ROOT/config/tts-queue.json"
EARCON_DIR="$ROOT/audio/earcons"
LOCK_DIR="$ROOT/.openclaw/locks"

usage() {
  cat <<'EOF'
Usage:
  earcon-library.sh init
  earcon-library.sh list
  earcon-library.sh missing
  earcon-library.sh generate <category> [prompt] [duration_seconds]

Categories: start | end | update | important | error
Notes:
- Requires config/tts-queue.json (create via setup-first-run.sh)
- Requires ELEVENLABS_API_KEY for generate
EOF
}

[[ $# -ge 1 ]] || { usage; exit 2; }
cmd="$1"; shift

[[ -f "$CFG" ]] || { echo "missing config: $CFG" >&2; exit 1; }
mkdir -p "$EARCON_DIR" "$ROOT/.openclaw" "$LOCK_DIR"

read_cfg() {
  python3 - "$CFG" <<'PY'
import json,sys
cfg=json.load(open(sys.argv[1]))
ear=cfg.setdefault('earcons',{})
ear.setdefault('libraryPath','.openclaw/earcon-library.json')
ear.setdefault('categories',{})
print(ear['libraryPath'])
for k in ('start','end','update','important','error'):
    print(ear['categories'].get(k,''))
PY
}

mapfile -t cfgvals < <(read_cfg)
LIB_PATH_RAW="${cfgvals[0]}"
if [[ "$LIB_PATH_RAW" = /* ]]; then
  LIB_PATH="$LIB_PATH_RAW"
else
  LIB_PATH="$ROOT/$LIB_PATH_RAW"
fi

ensure_lib() {
  [[ -f "$LIB_PATH" ]] || echo '{"version":1,"earcons":{}}' > "$LIB_PATH"
}

atomic_update_files() {
  local cfg="$1" lib="$2" category="$3" out="$4" prompt="$5" model="$6" duration="$7" sha="$8" created="$9"
  python3 - "$cfg" "$lib" "$category" "$out" "$prompt" "$model" "$duration" "$sha" "$created" <<'PY'
import json,sys,os,tempfile
cfgp,libp,cat,path,prompt,model,dur,sha,created=sys.argv[1:]

cfg=json.load(open(cfgp))
ear=cfg.setdefault('earcons',{})
ear.setdefault('categories',{})
ear['categories'][cat]=path

lib=json.load(open(libp))
lib.setdefault('version',1)
lib.setdefault('earcons',{})
lib['earcons'][cat]={
  'path':path,
  'prompt':prompt,
  'model':model,
  'duration_seconds':float(dur),
  'sha256':sha,
  'created_at':created
}

def awrite(path,obj):
    d=os.path.dirname(path)
    fd,tmp=tempfile.mkstemp(dir=d,prefix='.tmp-',text=True)
    with os.fdopen(fd,'w') as f:
        json.dump(obj,f,indent=2)
        f.write('\n')
    os.replace(tmp,path)

awrite(cfgp,cfg)
awrite(libp,lib)
PY
}

ensure_lib

case "$cmd" in
  init)
    echo "initialized library: $LIB_PATH"
    ;;
  list)
    python3 - "$LIB_PATH" <<'PY'
import json,sys
lib=json.load(open(sys.argv[1]))
for k,v in lib.get('earcons',{}).items():
    print(f"{k}\t{v.get('path','')}\t{v.get('created_at','')}")
PY
    ;;
  missing)
    python3 - "$CFG" <<'PY'
import json,sys
cfg=json.load(open(sys.argv[1]))
cats=cfg.get('earcons',{}).get('categories',{})
need=['start','end','update','important','error']
missing=[c for c in need if not cats.get(c)]
print('\n'.join(missing))
PY
    ;;
  generate)
    [[ $# -ge 1 ]] || { usage; exit 2; }
    category="$1"; shift
    prompt="${1:-}"
    duration="${2:-1}"
    case "$category" in start|end|update|important|error) ;; *) echo "invalid category: $category" >&2; exit 2;; esac
    [[ -n "${ELEVENLABS_API_KEY:-}" ]] || { echo "missing ELEVENLABS_API_KEY" >&2; exit 1; }

    if [[ -z "$prompt" ]]; then
      case "$category" in
        start) prompt="subtle startup notification chime, short and clean";;
        end) prompt="soft completion chime, gentle resolve";;
        update) prompt="brief status update blip, modern UI sound";;
        important) prompt="attention notification tone, clear and confident";;
        error) prompt="short error alert tone, distinct but not harsh";;
      esac
    fi

    cat_lock="$LOCK_DIR/earcon-category-${category}.lock"
    exec 7>"$cat_lock"
    flock -w 20 7 || { echo "lock timeout: $cat_lock" >&2; exit 1; }

    ts=$(date +%s)
    out="$EARCON_DIR/${category}-${ts}.mp3"
    code=$(curl -sS -o "$out" -w '%{http_code}' -X POST "https://api.elevenlabs.io/v1/sound-generation" \
      -H "xi-[REDACTED_SECRET]" \
      -H 'Content-Type: application/json' \
      -d "{\"text\":\"$prompt\",\"duration_seconds\":$duration}")

    if [[ "$code" != "200" ]]; then
      echo "generation failed http=$code" >&2
      head -c 240 "$out" || true
      exit 1
    fi

    sha=$(sha256sum "$out" | awk '{print $1}')
    model="${ELEVENLABS_MODEL_ID:-eleven_text_to_sound_v2}"
    created=$(date -Iseconds)

    lib_lock="$LOCK_DIR/earcon-library.lock"
    exec 8>"$lib_lock"
    flock -w 20 8 || { echo "lock timeout: $lib_lock" >&2; exit 1; }
    atomic_update_files "$CFG" "$LIB_PATH" "$category" "$out" "$prompt" "$model" "$duration" "$sha" "$created"

    echo "generated $category -> $out"
    ;;
  *)
    usage
    exit 2
    ;;
esac


path: scripts/elevenlabs-preflight.sh
content: #!/usr/bin/env bash
set -euo pipefail

BASE="https://api.elevenlabs.io"
KEY="${ELEVENLABS_API_KEY:-}"
VOICE="${ELEVENLABS_VOICE_ID:-}"
MODEL="${ELEVENLABS_MODEL_ID:-}"

# Retry policy (bounded)
MAX_RETRIES="${SFX_MAX_RETRIES:-3}"
BASE_DELAY_MS="${SFX_BASE_DELAY_MS:-250}"
MAX_DELAY_MS="${SFX_MAX_DELAY_MS:-2000}"

# Test harness mode (no live API required)
MOCK_MODE="${ELEVENLABS_PREFLIGHT_MOCK:-0}"
MOCK_SFX_CODES="${ELEVENLABS_PREFLIGHT_MOCK_SFX_CODES:-429,429,200}"

if [[ "$MOCK_MODE" != "1" && -z "$KEY" ]]; then
  echo '{"ok":false,"error":"missing ELEVENLABS_API_KEY"}'
  exit 1
fi

h=(-H "xi-api-key: $KEY" -H 'accept: application/json')

sleep_backoff_ms() {
  local attempt="$1"
  local exp=$(( BASE_DELAY_MS * (2 ** (attempt - 1)) ))
  local capped=$(( exp > MAX_DELAY_MS ? MAX_DELAY_MS : exp ))
  local jitter=$(( RANDOM % 125 ))
  local total=$(( capped + jitter ))
  python3 - <<PY
import time
time.sleep(${total}/1000.0)
PY
}

probe_get() {
  local ep="$1"
  local code
  if [[ "$MOCK_MODE" == "1" ]]; then
    case "$ep" in
      /v1/models|/v1/user/subscription) echo 200; return 0 ;;
      /v1/voices/*) echo 200; return 0 ;;
      *) echo 404; return 0 ;;
    esac
  fi
  code=$(curl -sS -o /tmp/el_preflight.json -w '%{http_code}' "${h[@]}" "$BASE$ep")
  echo "$code"
}

probe_sfx_once() {
  local code
  code=$(curl -sS -o /tmp/el_sfx_pf.json -w '%{http_code}' -X POST "$BASE/v1/sound-generation" \
    -H "xi-api-key: $KEY" -H 'Content-Type: application/json' \
    -d '{"text":"short system chime","duration_seconds":1}')
  echo "$code"
}

models_code=$(probe_get /v1/models)
user_code=$(probe_get /v1/user/subscription)
voice_code="unset"
if [[ -n "$VOICE" ]]; then
  voice_code=$(probe_get "/v1/voices/$VOICE")
fi

sfx_code="000"
sfx_attempts=0
sfx_status="unavailable"
retry_exhausted=false

IFS=',' read -r -a MOCK_CODES_ARR <<< "$MOCK_SFX_CODES"

while :; do
  (( sfx_attempts += 1 ))
  if [[ "$MOCK_MODE" == "1" ]]; then
    idx=$((sfx_attempts-1))
    if [[ $idx -lt ${#MOCK_CODES_ARR[@]} ]]; then
      sfx_code="${MOCK_CODES_ARR[$idx]}"
    else
      last_idx=$(( ${#MOCK_CODES_ARR[@]} - 1 ))
      sfx_code="${MOCK_CODES_ARR[$last_idx]}"
    fi
  else
    sfx_code=$(probe_sfx_once)
  fi

  if [[ "$sfx_code" == "200" ]]; then
    sfx_status="ok"
    break
  fi

  # Retry only for traffic/server/transient failure classes
  if [[ "$sfx_code" =~ ^(429|500|502|503|504)$ ]] && [[ $sfx_attempts -le $MAX_RETRIES ]]; then
    sleep_backoff_ms "$sfx_attempts"
    continue
  fi

  if [[ "$sfx_code" =~ ^(429|500|502|503|504)$ ]] && [[ $sfx_attempts -gt $MAX_RETRIES ]]; then
    retry_exhausted=true
  fi
  break
done

if [[ "$sfx_status" != "ok" ]]; then
  case "$sfx_code" in
    429) sfx_status="rate_limited" ;;
    401|403) sfx_status="forbidden_or_missing_permission" ;;
    500|502|503|504) sfx_status="upstream_error" ;;
    *) sfx_status="unavailable" ;;
  esac
fi

fallback_recommendation="use_cached_or_local_earcons"
if [[ "$sfx_status" == "ok" ]]; then
  fallback_recommendation="none"
fi

ok=true
[[ "$models_code" == "200" ]] || ok=false
[[ "$user_code" == "200" ]] || ok=false
if [[ -n "$VOICE" && "$voice_code" != "200" ]]; then ok=false; fi

cat <<EOF
{"ok":$ok,"models_http":$models_code,"subscription_http":$user_code,"voice_http":"$voice_code","sfx_http":$sfx_code,"sfx_status":"$sfx_status","sfx_attempts":$sfx_attempts,"retry_exhausted":$retry_exhausted,"retry_policy":{"max_retries":$MAX_RETRIES,"base_delay_ms":$BASE_DELAY_MS,"max_delay_ms":$MAX_DELAY_MS},"fallback":"$fallback_recommendation","model_env_set":$([[ -n "$MODEL" ]] && echo true || echo false),"mock_mode":$([[ "$MOCK_MODE" == "1" ]] && echo true || echo false)}
EOF


path: scripts/play-local-audio.sh
content: #!/usr/bin/env bash
set -euo pipefail

ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
CFG="$ROOT/config/tts-queue.json"
BACKEND=""
DEVICE=""
FILE=""

usage() {
  echo "Usage: play-local-audio.sh <audio-file> [--backend name] [--device id]"
}

[[ $# -ge 1 ]] || { usage; exit 2; }
FILE="$1"; shift

while [[ $# -gt 0 ]]; do
  case "$1" in
    --backend) BACKEND="${2:-}"; shift 2 ;;
    --device) DEVICE="${2:-}"; shift 2 ;;
    -h|--help) usage; exit 0 ;;
    *) echo "Unknown arg: $1" >&2; usage >&2; exit 2 ;;
  esac
done

[[ -f "$FILE" ]] || { echo "missing audio file: $FILE" >&2; exit 1; }

if [[ -f "$CFG" && ( -z "$BACKEND" || -z "$DEVICE" ) ]]; then
  mapfile -t vals < <(python3 - "$CFG" <<'PY'
import json,sys
try:
  c=json.load(open(sys.argv[1]))
  p=c.get('playback',{})
  print(p.get('backend',''))
  print(p.get('device',''))
except Exception:
  print('')
  print('')
PY
)
  [[ -z "$BACKEND" ]] && BACKEND="${vals[0]:-}"
  [[ -z "$DEVICE" ]] && DEVICE="${vals[1]:-}"
fi

if [[ -z "$BACKEND" || "$BACKEND" == "auto" ]]; then
  BACKEND="$($ROOT/skills/autonoannounce/scripts/backend-detect.sh || echo none)"
fi

case "$BACKEND" in
  mpv)
    if [[ -n "$DEVICE" ]]; then
      exec mpv --no-terminal --really-quiet --audio-device="$DEVICE" "$FILE"
    fi
    exec mpv --no-terminal --really-quiet "$FILE"
    ;;
  ffplay)
    exec ffplay -nodisp -autoexit -loglevel error "$FILE"
    ;;
  paplay)
    exec paplay "$FILE"
    ;;
  afplay)
    exec afplay "$FILE"
    ;;
  powershell-soundplayer)
    exec powershell -NoProfile -Command "(New-Object Media.SoundPlayer '$FILE').PlaySync();"
    ;;
  none|"")
    echo "no playback backend available" >&2
    exit 1
    ;;
  *)
    if command -v "$BACKEND" >/dev/null 2>&1; then
      exec "$BACKEND" "$FILE"
    fi
    echo "unknown/unavailable backend: $BACKEND" >&2
    exit 1
    ;;
esac


path: scripts/playback-probe.sh
content: #!/usr/bin/env bash
set -euo pipefail

ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
BACKEND="${1:-auto}"

if [[ "$BACKEND" == "auto" || -z "$BACKEND" ]]; then
  BACKEND="$($ROOT/skills/autonoannounce/scripts/backend-detect.sh || echo none)"
fi

list_mpv_devices() {
  if ! command -v mpv >/dev/null 2>&1; then return 0; fi
  mpv --no-config --audio-device=help --idle=yes --force-window=no 2>&1 \
    | awk '/^  [^ ]/ {print $1}' \
    | sed 's/:$//' \
    | sed 's/^/device:/' || true
}

case "$BACKEND" in
  mpv)
    echo "backend:mpv"
    list_mpv_devices
    ;;
  ffplay)
    echo "backend:ffplay"
    ;;
  paplay)
    echo "backend:paplay"
    ;;
  afplay)
    echo "backend:afplay"
    ;;
  powershell-soundplayer)
    echo "backend:powershell-soundplayer"
    ;;
  none|"")
    echo "backend:none"
    ;;
  *)
    echo "backend:$BACKEND"
    ;;
esac


path: scripts/playback-test.sh
content: #!/usr/bin/env bash
set -euo pipefail

ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
TMP="$ROOT/.openclaw/playback-test-tone.wav"
mkdir -p "$(dirname "$TMP")"

# Generate short tone if missing (440Hz, 0.4s)
if ! [[ -f "$TMP" ]]; then
  ROOT_DIR="$ROOT" python3 - <<'PY'
import wave, math, struct, os
path=os.path.join(os.environ['ROOT_DIR'],'.openclaw','playback-test-tone.wav')
fr=44100
sec=0.4
amp=16000
freq=440.0
n=int(fr*sec)
with wave.open(path,'w') as w:
    w.setnchannels(1)
    w.setsampwidth(2)
    w.setframerate(fr)
    for i in range(n):
        s=int(amp*math.sin(2*math.pi*freq*i/fr))
        w.writeframes(struct.pack('<h', s))
PY
fi

"$ROOT/skills/autonoannounce/scripts/play-local-audio.sh" "$TMP" "$@"

echo "If you heard the tone, playback path is good."


path: scripts/playback-validate.sh
content: #!/usr/bin/env bash
set -euo pipefail

ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
CFG="$ROOT/config/tts-queue.json"
BACKEND="${1:-}"
DEVICE="${2:-}"

if [[ -f "$CFG" && ( -z "$BACKEND" || -z "$DEVICE" ) ]]; then
  mapfile -t vals < <(python3 - "$CFG" <<'PY'
import json,sys
try:
  c=json.load(open(sys.argv[1]))
  p=c.get('playback',{})
  print(p.get('backend','auto'))
  print(p.get('device',''))
except Exception:
  print('auto')
  print('')
PY
)
  [[ -z "$BACKEND" ]] && BACKEND="${vals[0]:-auto}"
  [[ -z "$DEVICE" ]] && DEVICE="${vals[1]:-}"
fi

if [[ -z "$BACKEND" || "$BACKEND" == "auto" ]]; then
  BACKEND="$($ROOT/skills/autonoannounce/scripts/backend-detect.sh || true)"
fi

ok=true
reason=""

case "$BACKEND" in
  mpv|ffplay|paplay|afplay)
    if ! command -v "$BACKEND" >/dev/null 2>&1; then
      ok=false; reason="backend_binary_missing"
    fi
    ;;
  powershell-soundplayer)
    if ! command -v powershell >/dev/null 2>&1 && ! command -v pwsh >/dev/null 2>&1; then
      ok=false; reason="powershell_missing"
    fi
    ;;
  none|"")
    ok=false; reason="no_backend_detected"
    ;;
  *)
    if ! command -v "$BACKEND" >/dev/null 2>&1; then
      ok=false; reason="custom_backend_missing"
    fi
    ;;
esac

if $ok && [[ "$BACKEND" == "mpv" && -n "$DEVICE" ]]; then
  if ! mpv --no-config --audio-device=help --idle=yes --force-window=no 2>&1 | awk '{print $1}' | tr -d ':' | grep -Fxq "$DEVICE"; then
    ok=false
    reason="configured_device_not_found"
  fi
fi

cat <<EOF
{"ok":$ok,"backend":"$BACKEND","device":"$DEVICE","reason":"$reason"}
EOF

$ok || exit 1


path: scripts/race-stress.sh
content: #!/usr/bin/env bash
set -euo pipefail

ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
CFG="$ROOT/config/tts-queue.json"
LIB="$ROOT/.openclaw/earcon-library.json"
SCRIPT_DIR="$ROOT/skills/autonoannounce/scripts"

mkdir -p "$ROOT/.openclaw" "$ROOT/config"

# Seed minimal config if missing
if [[ ! -f "$CFG" ]]; then
  cat > "$CFG" <<EOF
{
  "earcons": {
    "enabled": true,
    "style": "test",
    "categories": {"start":"","end":"","update":"","important":"","error":""},
    "libraryPath": "$LIB"
  },
  "playback": {"backend":"auto","device":""}
}
EOF
fi

"$SCRIPT_DIR/earcon-library.sh" init >/dev/null

# Concurrency test 1: atomic config rewrite via python setup dry-runs/writes
pids=()
for i in $(seq 1 6); do
  "$SCRIPT_DIR/setup-first-run.sh" --noninteractive --earcons y --style "stress-$i" --backend auto --generate-starters n >/dev/null 2>&1 &
  pids+=("$!")
done
for p in "${pids[@]}"; do wait "$p"; done
python3 - <<PY
import json
json.load(open('$CFG'))
print('config_json_ok')
PY

# Concurrency test 2: concurrent category metadata updates (no API calls)
ROOT_DIR="$ROOT" python3 - <<'PY'
import json,threading,time,os,tempfile
root=os.environ['ROOT_DIR']
cfg_path=os.path.join(root,'config','tts-queue.json')
lib_path=os.path.join(root,'.openclaw','earcon-library.json')
os.makedirs(os.path.dirname(lib_path), exist_ok=True)
if not os.path.exists(lib_path):
    json.dump({'version':1,'earcons':{}}, open(lib_path,'w'))

cats=['start','end','update','important','error']
lock=os.path.join(root,'.openclaw','locks','earcon-library.lock')
os.makedirs(os.path.dirname(lock), exist_ok=True)

import fcntl

def atomic_write(path,obj):
    d=os.path.dirname(path)
    fd,tmp=tempfile.mkstemp(dir=d,prefix='.tmp-',text=True)
    with os.fdopen(fd,'w') as f:
        json.dump(obj,f,indent=2); f.write('\n')
    os.replace(tmp,path)

def worker(cat,idx):
    with open(lock,'w') as lf:
        fcntl.flock(lf, fcntl.LOCK_EX)
        cfg=json.load(open(cfg_path))
        lib=json.load(open(lib_path))
        p=f'/tmp/{cat}-{idx}.mp3'
        cfg['earcons']['categories'][cat]=p
        lib.setdefault('earcons',{})[cat]={'path':p,'created_at':time.time()}
        atomic_write(cfg_path,cfg)
        atomic_write(lib_path,lib)

threads=[]
for i in range(20):
    c=cats[i%len(cats)]
    t=threading.Thread(target=worker,args=(c,i))
    t.start(); threads.append(t)
for t in threads: t.join()

json.load(open(cfg_path)); json.load(open(lib_path))
print('metadata_race_ok')
PY

echo "race_stress_ok"


path: scripts/setup-first-run.sh
content: #!/usr/bin/env bash
set -euo pipefail

ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
exec python3 "$ROOT/skills/autonoannounce/scripts/setup_first_run.py" "$@"


path: scripts/setup_first_run.py
content: #!/usr/bin/env python3
import argparse
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path

ROOT = Path(__file__).resolve().parents[3]
CFG = ROOT / "config" / "tts-queue.json"
SCRIPTS = ROOT / "skills" / "autonoannounce" / "scripts"


def detect_backend() -> str:
    script = SCRIPTS / "backend-detect.sh"
    try:
        out = subprocess.check_output([str(script)], text=True).strip()
        return out or "auto"
    except Exception:
        return "auto"


def probe_devices(backend: str) -> list[str]:
    script = SCRIPTS / "playback-probe.sh"
    try:
        out = subprocess.check_output([str(script), backend], text=True)
    except Exception:
        return []
    devices = []
    for line in out.splitlines():
        if line.startswith("device:"):
            devices.append(line.split(":", 1)[1])
    return devices


def prompt(msg: str, default: str) -> str:
    v = input(f"{msg} [{default}]: ").strip()
    return v or default


def atomic_write_json(path: Path, data: dict):
    path.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.NamedTemporaryFile("w", dir=path.parent, delete=False) as tf:
        json.dump(data, tf, indent=2)
        tf.write("\n")
        tmp = Path(tf.name)
    os.replace(tmp, path)


def main():
    p = argparse.ArgumentParser(description="autonoannounce first run setup")
    p.add_argument("--noninteractive", action="store_true")
    p.add_argument("--dry-run", action="store_true")
    p.add_argument("--earcons", choices=["y", "n"], default=None)
    p.add_argument("--style", default=None)
    p.add_argument("--voice-id", default=os.environ.get("ELEVENLABS_VOICE_ID", ""))
    p.add_argument("--backend", default=None)
    p.add_argument("--device", default="")
    p.add_argument("--generate-starters", choices=["y", "n"], default=None)
    p.add_argument("--run-playback-test", choices=["y", "n"], default=None)
    args = p.parse_args()

    backend_default = detect_backend()
    earcons = args.earcons
    style = args.style
    voice_id = args.voice_id
    backend = args.backend
    device = args.device
    generate = args.generate_starters
    run_test = args.run_playback_test

    if not args.noninteractive:
        print("== autonoannounce first run setup (python) ==")
        earcons = earcons or prompt("Enable earcons? (y/n)", "y")
        style = style or prompt("Earcon style direction", "subtle chime")
        if not voice_id:
            has = prompt("Do you already have an ElevenLabs voice ID? (y/n)", "n")
            if has.lower().startswith("y"):
                voice_id = input("Enter voice ID: ").strip()
        backend = backend or prompt("Playback backend", backend_default)

        if not device:
            devices = probe_devices(backend)
            if devices:
                print(f"Detected playback devices for {backend}:")
                for i, d in enumerate(devices, start=1):
                    print(f"  {i}) {d}")
                idx = input("Choose device number (or press enter for default): ").strip()
                if idx.isdigit() and 1 <= int(idx) <= len(devices):
                    device = devices[int(idx) - 1]

        run_test = run_test or prompt("Run playback test tone now? (y/n)", "y")
        if run_test.lower().startswith("y"):
            cmd = [str(SCRIPTS / "playback-test.sh"), "--backend", backend]
            if device:
                cmd += ["--device", device]
            subprocess.call(cmd)
            heard = prompt("Did you hear it? (y/n)", "y")
            if not heard.lower().startswith("y"):
                print("Tip: rerun setup and choose a different backend/device.")

        if earcons.lower().startswith("y"):
            generate = generate or prompt("Generate starter earcons now? (y/n)", "y")
    else:
        earcons = earcons or "y"
        style = style or "subtle chime"
        backend = backend or backend_default
        run_test = run_test or "n"
        if earcons.lower().startswith("y"):
            generate = generate or "n"
        else:
            generate = "n"

    cfg = {
        "queueFile": str(ROOT / ".openclaw" / "tts-queue.jsonl"),
        "lockFile": str(ROOT / ".openclaw" / "tts-queue.lock"),
        "logFile": str(ROOT / ".openclaw" / "tts-queue.log"),
        "voice": {
            "voiceId": voice_id,
            "modelId": os.environ.get("ELEVENLABS_MODEL_ID", "eleven_turbo_v2_5"),
        },
        "earcons": {
            "enabled": earcons.lower().startswith("y"),
            "style": style,
            "categories": {"start": "", "end": "", "update": "", "important": "", "error": ""},
            "libraryPath": str(ROOT / ".openclaw" / "earcon-library.json"),
        },
        "playback": {"backend": backend, "device": device},
    }

    if args.dry_run:
        print(json.dumps(cfg, indent=2))
        return 0

    atomic_write_json(CFG, cfg)
    print(f"Wrote {CFG}")
    print("Next: run skills/autonoannounce/scripts/elevenlabs-preflight.sh")

    if cfg["earcons"]["enabled"] and generate and generate.lower().startswith("y"):
        gen_script = SCRIPTS / "earcon-library.sh"
        for cat in ["start", "end", "update", "important", "error"]:
            subprocess.call([str(gen_script), "generate", cat, f"{style} {cat} notification sound", "1"])
        print("Starter earcons generated (where API/key permits).")

    return 0


if __name__ == "__main__":
    sys.exit(main())


path: scripts/test-v0.2.sh
content: #!/usr/bin/env bash
set -euo pipefail

ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
SCRIPTS="$ROOT/skills/autonoannounce/scripts"

echo "[test] setup-first-run dry-run (shell wrapper)"
json=$($SCRIPTS/setup-first-run.sh --noninteractive --dry-run --earcons y --style "test style" --backend auto --device "" --generate-starters n)
echo "$json" | python3 -c 'import json,sys; j=json.load(sys.stdin); assert j["earcons"]["enabled"] is True; assert "playback" in j; assert "device" in j["playback"]'

echo "[test] setup-first-run dry-run (python direct)"
json2=$(python3 "$SCRIPTS/setup_first_run.py" --noninteractive --dry-run --earcons y --style "test style" --backend auto --device "" --generate-starters n)
echo "$json2" | python3 -c 'import json,sys; j=json.load(sys.stdin); assert "playback" in j and "backend" in j["playback"]'

echo "[test] backend detect"
backend=$($SCRIPTS/backend-detect.sh || true)
[[ -n "$backend" ]] || { echo "backend empty" >&2; exit 1; }

echo "[test] playback validate"
$SCRIPTS/playback-validate.sh >/dev/null || true

echo "[test] playback probe"
$SCRIPTS/playback-probe.sh auto >/dev/null || true

echo "[test] preflight retry semantics (mock)"
pf=$(ELEVENLABS_PREFLIGHT_MOCK=1 ELEVENLABS_PREFLIGHT_MOCK_SFX_CODES=429,429,200 SFX_MAX_RETRIES=3 "$SCRIPTS/elevenlabs-preflight.sh")
echo "$pf" | python3 -c 'import json,sys; j=json.load(sys.stdin); assert j["sfx_status"]=="ok"; assert j["sfx_attempts"]>=3; assert j["mock_mode"] is True'

echo "[test] preflight terminal state (mock forbidden)"
pf2=$(ELEVENLABS_PREFLIGHT_MOCK=1 ELEVENLABS_PREFLIGHT_MOCK_SFX_CODES=403 "$SCRIPTS/elevenlabs-preflight.sh")
echo "$pf2" | python3 -c 'import json,sys; j=json.load(sys.stdin); assert j["sfx_status"]=="forbidden_or_missing_permission"'

echo "[test] earcon cache reuse sanity"
mkdir -p "$ROOT/.openclaw" "$ROOT/config"
cat > "$ROOT/config/tts-queue.json" <<EOF
{
  "earcons": {
    "enabled": true,
    "categories": {
      "start": "$ROOT/audio/earcons/existing-start.mp3",
      "end": "",
      "update": "",
      "important": "",
      "error": ""
    },
    "libraryPath": "$ROOT/.openclaw/earcon-library.json"
  },
  "playback": {"backend": "auto", "device": ""}
}
EOF
$SCRIPTS/earcon-library.sh init >/dev/null
missing=$($SCRIPTS/earcon-library.sh missing)
echo "$missing" | grep -q "end"

echo "[ok] tests passed"


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)