Repository: dharmab/skyeye
Pull request #206 (2024-08-19 15:43:28)

Added possibility to use config file instead of command line arguments (or both together)
fixes: https://github.com/dharmab/skyeye/issues/204


Patch:
diff --git a/.github/workflows/skyeye.yaml b/.github/workflows/skyeye.yaml
index f9b12d8..10f9fbf 100644
--- a/.github/workflows/skyeye.yaml
+++ b/.github/workflows/skyeye.yaml
@@ -116,6 +116,17 @@ jobs:
           path: dist/skyeye-windows-amd64.zip
           if-no-files-found: error
           overwrite: true
+  build-image:
+    name: Build container image
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+      - name: Build image
+        uses: docker/build-push-action@v6
+        with:
+          build-args: |
+            SKYEYE_VERSION: ${{ env.GITHUB_REF_NAME }}
   release:
     name: Upload release assets
     if: startsWith(github.ref, 'refs/tags/')
@@ -141,3 +152,47 @@ jobs:
             dist/skyeye-linux-amd64.tar.gz
             dist/skyeye-windows-amd64.zip
           token: ${{ secrets.RELEASE_TOKEN }}
+  push-image:
+    name: Push container image
+    if: startsWith(github.ref, 'refs/tags/')
+    needs:
+      - lint
+      - test
+    env:
+      REGISTRY: ghcr.io
+      IMAGE_NAME: ${{ github.repository }}
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+      packages: write
+      attestations: write
+      id-token: write
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+      - name: Log in to the Container registry
+        uses: docker/login-action@v3
+        with:
+          registry: ${{ env.REGISTRY }}
+          username: ${{ github.actor }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+      - name: Extract metadata (tags, labels) for Docker
+        id: meta
+        uses: docker/metadata-action@v5
+        with:
+          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+      - name: Build and push Docker image
+        id: push
+        uses: docker/build-push-action@v6
+        with:
+          build-args: |
+            SKYEYE_VERSION: ${{ env.GITHUB_REF_NAME }}
+          push: true
+          tags: ${{ steps.meta.outputs.tags }}
+          labels: ${{ steps.meta.outputs.labels }}
+      - name: Generate artifact attestation
+        uses: actions/attest-build-provenance@v1
+        with:
+          subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
+          subject-digest: ${{ steps.push.outputs.digest }}
+          push-to-registry: true
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 90e0e71..71de67b 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -5,9 +5,6 @@
         "LIBRARY_PATH": "${workspaceFolder}/third_party/whisper.cpp/",
         "GOROOT": "c:\\msys64\\mingw64\\lib\\go",
     },
-    "go.alternateTools": {
-        "go": "c:\\msys64\\mingw64\\bin\\go.exe",
-    },
     "logViewer.watch": [
         {
             "title": "DCS SRS Server Log",
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..b1d814b
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,29 @@
+ARG SKYEYE_VERSION
+FROM golang:1.23.0 AS builder
+RUN apt-get update && apt-get install -y \
+  git \
+  make \
+  lsb-release \
+  gcc \
+  libopus-dev \
+  libsoxr-dev \
+&& rm -rf /var/lib/apt/lists/*
+WORKDIR /skyeye
+COPY third_party third_party
+COPY Makefile Makefile
+RUN make whisper
+COPY go.mod go.mod
+COPY go.sum go.sum
+RUN go mod download -x
+COPY cmd cmd
+COPY internal internal
+COPY pkg pkg
+RUN make skyeye
+
+FROM debian:bookworm-slim AS skyeye
+RUN apt-get update && apt-get install -y \
+  libopus0 \
+  libsoxr0 \
+&& rm -rf /var/lib/apt/lists/*
+COPY --from=builder /skyeye/skyeye /opt/skyeye/bin/skyeye
+ENTRYPOINT ["/opt/skyeye/bin/skyeye"]
diff --git a/Makefile b/Makefile
index e081ff8..9e9b2ed 100644
--- a/Makefile
+++ b/Makefile
@@ -1,9 +1,9 @@
 GO = go
 
 # Detect CPU architecture
-ifeq ($(shell uname -m),arm64) 
+ifeq ($(shell uname -m),arm64)
 GOARCH = arm64
-else ifeq ($(shell uname -m),x86_64) 
+else ifeq ($(shell uname -m),x86_64)
 GOARCH = amd64
 endif
 
@@ -44,7 +44,10 @@ BUILD_VARS = CGO_ENABLED=1 \
 BUILD_FLAGS = -tags nolibopusfile
 
 # Populate --version from Git tag
-LDFLAGS= -X "main.Version=$(shell git describe --tags || echo devel)"
+ifeq ($(SKYEYE_VERSION),)
+SKYEYE_VERSION=$(shell git describe --tags || echo devel)
+endif
+LDFLAGS= -X "main.Version=$(SKYEYE_VERSION)"
 
 ifeq ($(OS_DISTRIBUTION),Windows)
 # Static linking on Windows to avoid MSYS2 dependency at runtime
@@ -102,7 +105,7 @@ install-macos-dependencies:
 	  libsoxr
 
 $(LIBWHISPER_PATH) $(WHISPER_H_PATH):
-	if [[ ! -f $(LIBWHISPER_PATH) || ! -f $(WHISPER_H_PATH) ]]; then git -C "$(WHISPER_CPP_PATH)" checkout --quiet $(WHISPER_CPP_VERSION) || git clone --depth 1 --branch $(WHISPER_CPP_VERSION) -c advice.detachedHead=false https://github.com/ggerganov/whisper.cpp.git "$(WHISPER_CPP_PATH)" && make -C $(WHISPER_CPP_PATH)/bindings/go whisper; fi
+	if [ ! -f $(LIBWHISPER_PATH) -o ! -f $(WHISPER_H_PATH) ]; then git -C "$(WHISPER_CPP_PATH)" checkout --quiet $(WHISPER_CPP_VERSION) || git clone --depth 1 --branch $(WHISPER_CPP_VERSION) -c advice.detachedHead=false https://github.com/ggerganov/whisper.cpp.git "$(WHISPER_CPP_PATH)" && make -C $(WHISPER_CPP_PATH)/bindings/go whisper; fi
 
 .PHONY: whisper
 whisper: $(LIBWHISPER_PATH) $(WHISPER_H_PATH)
@@ -117,7 +120,6 @@ $(SKYEYE_EXE): generate $(SKYEYE_SOURCES) $(LIBWHISPER_PATH) $(WHISPER_H_PATH)
 $(SKYEYE_ELF): generate $(SKYEYE_SOURCES) $(LIBWHISPER_PATH) $(WHISPER_H_PATH)
 	$(BUILD_VARS) $(GO) build $(BUILD_FLAGS) ./cmd/skyeye/
 
-
 .PHONY: test
 test: generate
 	$(BUILD_VARS) $(GO) run gotest.tools/gotestsum -- $(BUILD_FLAGS) ./...
diff --git a/cmd/skyeye/main.go b/cmd/skyeye/main.go
index bd12f70..9eadee7 100644
--- a/cmd/skyeye/main.go
+++ b/cmd/skyeye/main.go
@@ -17,6 +17,8 @@ import (
 	"github.com/rs/zerolog"
 	"github.com/rs/zerolog/log"
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
+	"github.com/spf13/viper"
 
 	"github.com/dharmab/skyeye/internal/application"
 	"github.com/dharmab/skyeye/internal/conf"
@@ -25,8 +27,9 @@ import (
 	"github.com/ggerganov/whisper.cpp/bindings/go/pkg/whisper"
 )
 
-// Variables for CLI flags
+// Variables for CLI/Config flags
 var (
+	configFile                   string
 	logLevel                     string
 	logFormat                    string
 	acmiFile                     string
@@ -44,6 +47,7 @@ var (
 	whisperModelPath             string
 	voiceName                    string
 	playbackSpeed                string
+	playbackPause                time.Duration
 	enableAutomaticPicture       bool
 	automaticPictureInterval     time.Duration
 	enableThreatMonitoring       bool
@@ -52,7 +56,13 @@ var (
 	mandatoryThreatRadiusNM      float64
 )
 
+var (
+	envPrefix = "SKYEYE"
+)
+
 func init() {
+	skyeye.Flags().StringVar(&configFile, "config-file", ".", "Path to a config file e.g. '/home/user/xyz.yaml'")
+
 	// Logging
 	logLevelFlag := NewEnum(&logLevel, "Level", "info", "error", "warn", "info", "debug", "trace")
 	skyeye.Flags().Var(logLevelFlag, "log-level", "Log level (error, warn, info, debug, trace)")
@@ -87,6 +97,7 @@ func init() {
 	skyeye.Flags().Var(voiceFlag, "voice", "Voice to use for SRS transmissions (feminine, masculine)")
 	playbackSpeedFlag := NewEnum(&playbackSpeed, "string", "standard", "veryslow", "slow", "fast", "veryfast")
 	skyeye.Flags().Var(playbackSpeedFlag, "voice-playback-speed", "Voice playback speed of GCI")
+	skyeye.Flags().DurationVar(&playbackPause, "voice-playback-pause", 200*time.Millisecond, "Voice playback pause time after every sentence in seconds")
 
 	// Controller behavior
 	skyeye.Flags().BoolVar(&enableAutomaticPicture, "auto-picture", true, "Enable automatic PICTURE broadcasts")
@@ -105,7 +116,10 @@ var skyeye = &cobra.Command{
 	Long:    "Skyeye uses real-time telemetry data from TacView to provide Ground-Controlled Intercept service over SimpleRadio-Standalone.",
 	Example: strings.Join(
 		[]string{
-			"  " + "Remote TacView and SRS server",
+			"  " + "Custom Config Path",
+			"skyeye --config-file='/home/user/xyz.yaml'",
+			"",
+			"Remote TacView and SRS server",
 			"skyeye --telemetry-address=your-tacview-server:42674 --telemetry-password=your-tacview-password --srs-server-address=your-srs-server:5002 --srs-eam-password=your-srs-eam-password --whisper-model=ggml-small.en.bin",
 			"",
 			"Local TacView and SRS server",
@@ -114,7 +128,11 @@ var skyeye = &cobra.Command{
 		"\n  ",
 	),
 	PreRun: func(cmd *cobra.Command, args []string) {
-		if whisperModelPath == "" {
+		if err := initializeConfig(cmd); err != nil {
+			log.Info().Msg("Could not initialize config")
+		}
+
+		if whisperModelPath == "" && !viper.IsSet("whisper-model") {
 			_ = cmd.Help()
 			os.Exit(0)
 		}
@@ -131,6 +149,39 @@ func main() {
 	}
 }
 
+func initializeConfig(cmd *cobra.Command) error {
+	v := viper.New()
+
+	v.SetConfigFile(configFile)
+	if err := v.ReadInConfig(); err != nil {
+		// having no config file is fine
+		if !errors.Is(err, viper.ConfigFileNotFoundError{}) {
+			return err
+		}
+	}
+
+	v.SetEnvPrefix(envPrefix)
+	v.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
+	v.AutomaticEnv()
+
+	bindFlags(cmd, v)
+	return nil
+}
+
+func bindFlags(cmd *cobra.Command, v *viper.Viper) {
+	cmd.Flags().VisitAll(func(f *pflag.Flag) {
+		configName := f.Name
+
+		// Apply the viper config value to the flag when the flag is not set and viper has a value
+		if !f.Changed && v.IsSet(configName) {
+			val := v.Get(configName)
+			if err := cmd.Flags().Set(f.Name, fmt.Sprintf("%v", val)); err != nil {
+				log.Warn().Str("flag", f.Name).Msg("Failed to set flag")
+			}
+		}
+	})
+}
+
 func setupLogging() {
 	if strings.EqualFold(logFormat, "pretty") {
 		log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
@@ -302,6 +353,7 @@ func Supervise(cmd *cobra.Command, args []string) {
 		WhisperModel:                 whisperModel,
 		Voice:                        voice,
 		PlaybackSpeed:                playbackSpeed,
+		PlaybackPause:                playbackPause,
 		EnableThreatMonitoring:       enableThreatMonitoring,
 		ThreatMonitoringInterval:     threatMonitoringInterval,
 		ThreatMonitoringRequiresSRS:  threatMonitoringRequiresSRS,
diff --git a/docs/ADMIN.md b/docs/ADMIN.md
index daa9ca1..4e79927 100644
--- a/docs/ADMIN.md
+++ b/docs/ADMIN.md
@@ -73,6 +73,10 @@ On Windows, the easiest way to retain your logs is to use [redirection](https://
 
 Advanced users should consider sending their logs to a log aggregator such as [Grafana Cloud](https://grafana.com/products/cloud/logs/). If you do this, I also recommend using `--log-format=json` to log in JSON format, which is easier to search and filter when using an aggregator.
 
+## Config Path
+
+Instead of using command line arguments you can also create a file called `skyeye-config.yaml`. It will search for the file in the root folder of your skyeye.exe. You can also define a custom location with the `--config-path` parameter. There is an example file located in the repository. CLI arguments however will always override the config values.
+
 ## Gameplay Configuration
 
 There's a few options you'll choose when running the bot. You can see all available options using `--help`.
diff --git a/go.mod b/go.mod
index 1cf4f8a..8ba5210 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
 module github.com/dharmab/skyeye
 
-go 1.22.1
+go 1.23.0
 
 require (
 	github.com/amitybell/piper-asset v0.0.0-20231030194325-d36a29e3b1fd
@@ -12,13 +12,14 @@ require (
 	github.com/hbollon/go-edlib v1.6.0
 	github.com/lithammer/shortuuid/v3 v3.0.7
 	github.com/martinlindhe/unit v0.0.0-20230420213220-4adfd7d0a0d6
-	github.com/nabbl/piper v0.0.0-20240807143155-14d7a439759f
+	github.com/nabbl/piper v0.0.0-20240819160100-e51f2288a5c0
 	github.com/paulmach/orb v0.11.1
 	github.com/proway2/go-igrf v0.5.1
 	github.com/rodaine/numwords v0.0.0-20200910203654-405f4a455f79
 	github.com/rs/zerolog v1.33.0
 	github.com/spf13/cobra v1.8.1
 	github.com/spf13/pflag v1.0.5
+	github.com/spf13/viper v1.19.0
 	github.com/stretchr/testify v1.9.0
 	github.com/zaf/resample v1.5.0
 	gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302
@@ -67,14 +68,14 @@ require (
 	github.com/ckaznocha/intrange v0.1.2 // indirect
 	github.com/curioswitch/go-reassign v0.2.0 // indirect
 	github.com/daixiang0/gci v0.13.4 // indirect
-	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
 	github.com/denis-tingaikin/go-header v0.5.0 // indirect
 	github.com/dnephin/pflag v1.0.7 // indirect
 	github.com/ettle/strcase v0.2.0 // indirect
 	github.com/fatih/color v1.17.0 // indirect
 	github.com/fatih/structtag v1.2.0 // indirect
 	github.com/firefart/nonamedreturns v1.0.5 // indirect
-	github.com/fsnotify/fsnotify v1.5.4 // indirect
+	github.com/fsnotify/fsnotify v1.7.0 // indirect
 	github.com/fzipp/gocyclo v0.6.0 // indirect
 	github.com/ghostiam/protogetter v0.3.6 // indirect
 	github.com/go-critic/go-critic v0.11.4 // indirect
@@ -126,7 +127,7 @@ require (
 	github.com/leonklingele/grouper v1.1.2 // indirect
 	github.com/lufeee/execinquery v1.2.1 // indirect
 	github.com/macabu/inamedparam v0.1.3 // indirect
-	github.com/magiconair/properties v1.8.6 // indirect
+	github.com/magiconair/properties v1.8.7 // indirect
 	github.com/maratori/testableexamples v1.0.0 // indirect
 	github.com/maratori/testpackage v1.1.1 // indirect
 	github.com/matoous/godox v0.0.0-20230222163458-006bad1f9d26 // indirect
@@ -144,9 +145,8 @@ require (
 	github.com/nishanths/predeclared v0.2.2 // indirect
 	github.com/nunnatsa/ginkgolinter v0.16.2 // indirect
 	github.com/olekukonko/tablewriter v0.0.5 // indirect
-	github.com/pelletier/go-toml v1.9.5 // indirect
 	github.com/pelletier/go-toml/v2 v2.2.2 // indirect
-	github.com/pmezard/go-difflib v1.0.0 // indirect
+	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
 	github.com/polyfloyd/go-errorlint v1.6.0 // indirect
 	github.com/prometheus/client_golang v1.12.1 // indirect
 	github.com/prometheus/client_model v0.2.0 // indirect
@@ -159,6 +159,8 @@ require (
 	github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
 	github.com/ryancurrah/gomodguard v1.3.3 // indirect
 	github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect
+	github.com/sagikazarmark/locafero v0.4.0 // indirect
+	github.com/sagikazarmark/slog-shim v0.1.0 // indirect
 	github.com/sanposhiho/wastedassign/v2 v2.0.7 // indirect
 	github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect
 	github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
@@ -169,15 +171,14 @@ require (
 	github.com/sivchari/containedctx v1.0.3 // indirect
 	github.com/sivchari/tenv v1.10.0 // indirect
 	github.com/sonatard/noctx v0.0.2 // indirect
+	github.com/sourcegraph/conc v0.3.0 // indirect
 	github.com/sourcegraph/go-diff v0.7.0 // indirect
 	github.com/spf13/afero v1.11.0 // indirect
-	github.com/spf13/cast v1.5.0 // indirect
-	github.com/spf13/jwalterweatherman v1.1.0 // indirect
-	github.com/spf13/viper v1.12.0 // indirect
+	github.com/spf13/cast v1.6.0 // indirect
 	github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
 	github.com/stbenjam/no-sprintf-host-port v0.1.1 // indirect
 	github.com/stretchr/objx v0.5.2 // indirect
-	github.com/subosito/gotenv v1.4.1 // indirect
+	github.com/subosito/gotenv v1.6.0 // indirect
 	github.com/tdakkota/asciicheck v0.2.0 // indirect
 	github.com/tetafro/godot v1.4.16 // indirect
 	github.com/timakin/bodyclose v0.0.0-20230421092635-574207250966 // indirect
@@ -194,9 +195,9 @@ require (
 	gitlab.com/bosi/decorder v0.4.2 // indirect
 	go-simpler.org/musttag v0.12.2 // indirect
 	go-simpler.org/sloglint v0.7.2 // indirect
-	go.uber.org/atomic v1.7.0 // indirect
+	go.uber.org/atomic v1.9.0 // indirect
 	go.uber.org/automaxprocs v1.5.3 // indirect
-	go.uber.org/multierr v1.6.0 // indirect
+	go.uber.org/multierr v1.9.0 // indirect
 	go.uber.org/zap v1.24.0 // indirect
 	golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc // indirect
 	golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f // indirect
diff --git a/go.sum b/go.sum
index 38746dd..87951a5 100644
--- a/go.sum
+++ b/go.sum
@@ -140,8 +140,9 @@ github.com/curioswitch/go-reassign v0.2.0/go.mod h1:x6OpXuWvgfQaMGks2BZybTngWjT8
 github.com/daixiang0/gci v0.13.4 h1:61UGkmpoAcxHM2hhNkZEf5SzwQtWJXTSws7jaPyqwlw=
 github.com/daixiang0/gci v0.13.4/go.mod h1:12etP2OniiIdP4q+kjUGrC/rUagga7ODbqsom5Eo5Yk=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8=
 github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY=
 github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk=
@@ -161,8 +162,9 @@ github.com/firefart/nonamedreturns v1.0.5 h1:tM+Me2ZaXs8tfdDw3X6DOX++wMCOqzYUho6
 github.com/firefart/nonamedreturns v1.0.5/go.mod h1:gHJjDqhGM4WyPt639SOZs+G89Ko7QKH5R5BhnO6xJhw=
 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
-github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI=
 github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
+github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
+github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
 github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
 github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
 github.com/gammazero/deque v0.2.1 h1:qSdsbG6pgp6nL7A0+K/B7s12mcCY/5l5SIUpMOl+dC0=
@@ -394,8 +396,8 @@ github.com/lufeee/execinquery v1.2.1 h1:hf0Ems4SHcUGBxpGN7Jz78z1ppVkP/837ZlETPCE
 github.com/lufeee/execinquery v1.2.1/go.mod h1:EC7DrEKView09ocscGHC+apXMIaorh4xqSxS/dy8SbM=
 github.com/macabu/inamedparam v0.1.3 h1:2tk/phHkMlEL/1GNe/Yf6kkR/hkcUdAEY3L0hjYV1Mk=
 github.com/macabu/inamedparam v0.1.3/go.mod h1:93FLICAIk/quk7eaPPQvbzihUdn/QkGDwIZEoLtpH6I=
-github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
-github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
+github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
+github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
 github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI=
 github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE=
 github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04=
@@ -433,8 +435,8 @@ github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKH
 github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U=
 github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
 github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
-github.com/nabbl/piper v0.0.0-20240807143155-14d7a439759f h1:myqV4cNCgFdczDGogP+LzReopLvKIc8qZxACatYRfOU=
-github.com/nabbl/piper v0.0.0-20240807143155-14d7a439759f/go.mod h1:bFBWV8PBQEC05ZPi5L4cBMBZQg5STeec4GmL6nvnTg4=
+github.com/nabbl/piper v0.0.0-20240819160100-e51f2288a5c0 h1:U9cqEmB3rVgMp0A8zTSP3TnlmnMPrbwWApEXObTBKCQ=
+github.com/nabbl/piper v0.0.0-20240819160100-e51f2288a5c0/go.mod h1:bFBWV8PBQEC05ZPi5L4cBMBZQg5STeec4GmL6nvnTg4=
 github.com/nabbl/piper-bin-macos v0.0.0-20240805085459-7f1b1df8c68d h1:I3pOmUaoFE8Lvf5j++9hc/UaXfW21zodWT1sihHY57M=
 github.com/nabbl/piper-bin-macos v0.0.0-20240805085459-7f1b1df8c68d/go.mod h1:NIGeON0x6RckQptwA2jS7U89GcsjMXbzSBw5edGOw9A=
 github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U=
@@ -461,16 +463,15 @@ github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH
 github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU=
 github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
 github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
-github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
-github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
 github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
 github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v1.6.0 h1:tftWV9DE7txiFzPpztTAwyoRLKNj9gpVm2cg8/OwcYY=
 github.com/polyfloyd/go-errorlint v1.6.0/go.mod h1:HR7u8wuP1kb1NeN1zqTd1ZMlqUKPPHF+Id4vIPvDqVw=
 github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
@@ -523,6 +524,10 @@ github.com/ryancurrah/gomodguard v1.3.3 h1:eiSQdJVNr9KTNxY2Niij8UReSwR8Xrte3exBr
 github.com/ryancurrah/gomodguard v1.3.3/go.mod h1:rsKQjj4l3LXe8N344Ow7agAy5p9yjsWOtRzUMYmA0QY=
 github.com/ryanrolds/sqlclosecheck v0.5.1 h1:dibWW826u0P8jNLsLN+En7+RqWWTYrjCB9fJfSfdyCU=
 github.com/ryanrolds/sqlclosecheck v0.5.1/go.mod h1:2g3dUjoS6AL4huFdv6wn55WpLIDjY7ZgUR4J8HOO/XQ=
+github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
+github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
+github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
+github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
 github.com/sanposhiho/wastedassign/v2 v2.0.7 h1:J+6nrY4VW+gC9xFzUc+XjPD3g3wF3je/NsJFwFK7Uxc=
 github.com/sanposhiho/wastedassign/v2 v2.0.7/go.mod h1:KyZ0MWTwxxBmfwn33zh3k1dmsbF2ud9pAAGfoLfjhtI=
 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4=
@@ -548,21 +553,21 @@ github.com/sivchari/tenv v1.10.0 h1:g/hzMA+dBCKqGXgW8AV/1xIWhAvDrx0zFKNR48NFMg0=
 github.com/sivchari/tenv v1.10.0/go.mod h1:tdY24masnVoZFxYrHv/nD6Tc8FbkEtAQEEziXpyMgqY=
 github.com/sonatard/noctx v0.0.2 h1:L7Dz4De2zDQhW8S0t+KUjY0MAQJd6SgVwhzNIc4ok00=
 github.com/sonatard/noctx v0.0.2/go.mod h1:kzFz+CzWSjQ2OzIm46uJZoXuBpa2+0y3T36U18dWqIo=
+github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
+github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
 github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0=
 github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs=
 github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
 github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
-github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
-github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
+github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
+github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
 github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
 github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
-github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
-github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
 github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/viper v1.12.0 h1:CZ7eSOd3kZoaYDLbXnmzgQI5RlciuXBMA+18HwHRfZQ=
-github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiuKtSI=
+github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
+github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
 github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0=
 github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I=
 github.com/stbenjam/no-sprintf-host-port v0.1.1 h1:tYugd/yrm1O0dV+ThCbaKZh195Dfm07ysF0U6JQXczc=
@@ -584,8 +589,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs=
-github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
 github.com/tdakkota/asciicheck v0.2.0 h1:o8jvnUANo0qXtnslk2d3nMKTFNlOnJjRrNcj0j9qkHM=
 github.com/tdakkota/asciicheck v0.2.0/go.mod h1:Qb7Y9EgjCLJGup51gDHFzbI08/gbGhL/UVhYIPWG2rg=
 github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA=
@@ -644,14 +649,14 @@ go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
 go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
-go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
+go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
 go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8=
 go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0=
 go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
 go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
-go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
-go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
+go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
+go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
 go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
 go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
 golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
diff --git a/internal/application/app.go b/internal/application/app.go
index 17aef9b..a2a9b65 100644
--- a/internal/application/app.go
+++ b/internal/application/app.go
@@ -133,7 +133,7 @@ func NewApplication(ctx context.Context, config conf.Configuration) (Application
 	composer := composer.New(config.Callsign)
 
 	log.Info().Msg("constructing text-to-speech synthesizer")
-	synthesizer, err := speakers.NewPiperSpeaker(config.Voice, config.PlaybackSpeed)
+	synthesizer, err := speakers.NewPiperSpeaker(config.Voice, config.PlaybackSpeed, config.PlaybackPause)
 	if err != nil {
 		return nil, fmt.Errorf("failed to construct application: %w", err)
 	}
diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go
index e149043..da50626 100644
--- a/internal/conf/configuration.go
+++ b/internal/conf/configuration.go
@@ -44,6 +44,8 @@ type Configuration struct {
 	Voice voices.Voice
 	// Piper playback speed (default is 1.0) - The higher the value the slower it is.
 	PlaybackSpeed float32
+	// Piper playback pause after every sentence in seconds (default is 0.2)
+	PlaybackPause time.Duration
 	// PictureBroadcastInterval is the interval at which the controller will automatically broadcast a PICTURE.
 	PictureBroadcastInterval time.Duration
 	// EnableThreatMonitoring controls whether the controller will broadcast THREAT calls.
diff --git a/pkg/brevity/altitude.go b/pkg/brevity/altitude.go
index aa4b777..4657e16 100644
--- a/pkg/brevity/altitude.go
+++ b/pkg/brevity/altitude.go
@@ -1,9 +1,9 @@
 package brevity
 
 import (
-	"math"
 	"slices"
 
+	"github.com/dharmab/skyeye/pkg/spatial"
 	"github.com/martinlindhe/unit"
 )
 
@@ -16,7 +16,7 @@ type Stack struct {
 // Stacks creates altitude STACKS from altitudes.
 func Stacks(a ...unit.Length) []Stack {
 	for i, alt := range a {
-		a[i] = unit.Length(math.Round(alt.Feet()/1000)) * 1000 * unit.Foot
+		a[i] = spatial.NormalizeAltitude(alt)
 	}
 	// reverse sort
 	slices.SortFunc(a, func(i, j unit.Length) int {
diff --git a/pkg/brevity/braa.go b/pkg/brevity/braa.go
index b56ef5b..db13acc 100644
--- a/pkg/brevity/braa.go
+++ b/pkg/brevity/braa.go
@@ -4,6 +4,7 @@ import (
 	"math"
 
 	"github.com/dharmab/skyeye/pkg/bearings"
+	"github.com/dharmab/skyeye/pkg/spatial"
 	"github.com/martinlindhe/unit"
 	"github.com/rs/zerolog/log"
 )
@@ -60,7 +61,7 @@ func (b *bra) Altitude() unit.Length {
 	if len(b.stacks) == 0 {
 		return 0
 	}
-	return unit.Length(math.Round(b.stacks[0].Altitude.Feet()/1000)) * 1000 * unit.Foot
+	return spatial.NormalizeAltitude(b.stacks[0].Altitude)
 }
 
 // Stacks implements [BRA.Stacks].
diff --git a/pkg/coalitions/coalition.go b/pkg/coalitions/coalition.go
index 866f858..ab1036b 100644
--- a/pkg/coalitions/coalition.go
+++ b/pkg/coalitions/coalition.go
@@ -13,6 +13,18 @@ const (
 	Neutrals = 3
 )
 
+// String returns the string representation of a coalition.
+func (c Coalition) String() string {
+	switch c {
+	case Red:
+		return "Red"
+	case Blue:
+		return "Blue"
+	default:
+		return "Neutrals"
+	}
+}
+
 // All returns all coalitions.
 func All() []Coalition {
 	return []Coalition{Red, Blue, Neutrals}
diff --git a/pkg/composer/group.go b/pkg/composer/group.go
index f22c647..c4d305d 100644
--- a/pkg/composer/group.go
+++ b/pkg/composer/group.go
@@ -158,19 +158,21 @@ func (c *composer) ComposeAltitudeFillIns(stacks []brevity.Stack) string {
 }
 
 func (c *composer) ComposeAltitude(altitude unit.Length, declaration brevity.Declaration) string {
-	if altitude.Meters() < 100 {
+	hundreds := int(math.Round(altitude.Feet() / 100))
+	thousands := int(math.Round(altitude.Feet() / 1000))
+	if hundreds == 0 {
 		return "altitude unknown"
 	}
+
 	if declaration == brevity.Friendly {
-		if altitude.Feet() < 1000 {
-			hundreds := int(math.Round(altitude.Feet() / 100))
+		if altitude < 1000*unit.Foot {
 			return fmt.Sprintf("cherubs %d", hundreds)
 		}
-		thousands := int(math.Round(altitude.Feet() / 1000))
 		return fmt.Sprintf("angels %d", thousands)
 	}
-	if altitude > 1000 {
-		return fmt.Sprint(int(math.Round(altitude.Feet()/1000) * 1000))
+
+	if altitude < 1000*unit.Foot {
+		return fmt.Sprint(hundreds * 100)
 	}
-	return fmt.Sprint(int(math.Round(altitude.Feet()/100) * 100))
+	return fmt.Sprint(thousands * 1000)
 }
diff --git a/pkg/composer/spiked.go b/pkg/composer/spiked.go
index b664502..6355ffc 100644
--- a/pkg/composer/spiked.go
+++ b/pkg/composer/spiked.go
@@ -2,7 +2,6 @@ package composer
 
 import (
 	"fmt"
-	"math"
 	"slices"
 
 	"github.com/dharmab/skyeye/pkg/brevity"
@@ -12,10 +11,10 @@ import (
 func (c *composer) ComposeSpikedResponse(response brevity.SpikedResponse) NaturalLanguageResponse {
 	if response.Status {
 		reply := fmt.Sprintf(
-			"%s, spike range %d, %d, %s",
+			"%s, spike range %d, %s, %s",
 			response.Callsign,
 			int(response.Range.NauticalMiles()),
-			int(math.Round(response.Altitude.Feet()/1000)*1000),
+			c.ComposeAltitude(response.Altitude, brevity.Bogey),
 			response.Aspect)
 		isCardinalAspect := slices.Contains([]brevity.Aspect{brevity.Flank, brevity.Beam, brevity.Drag}, response.Aspect)
 		isTrackKnown := response.Track != brevity.UnknownDirection
diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go
index 5226f76..e0f5847 100644
--- a/pkg/controller/controller.go
+++ b/pkg/controller/controller.go
@@ -54,6 +54,7 @@ type controller struct {
 	enableThreatMonitoring      bool
 	threatMonitoringRequiresSRS bool
 	threatMonitoringCooldown    time.Duration
+	wasLastPictureClean         bool
 }
 
 func New(
@@ -113,7 +114,7 @@ func (c *controller) Run(ctx context.Context, out chan<- any) {
 			if time.Now().After(c.pictureBroadcastDeadline) {
 				logger := log.With().Logger()
 				logger.Info().Msg("broadcasting PICTURE call")
-				c.broadcastPicture(&logger)
+				c.broadcastPicture(&logger, false)
 			}
 			c.broadcastThreats()
 		}
diff --git a/pkg/controller/picture.go b/pkg/controller/picture.go
index 870bf8d..0d02b7a 100644
--- a/pkg/controller/picture.go
+++ b/pkg/controller/picture.go
@@ -14,18 +14,24 @@ func (c *controller) HandlePicture(request *brevity.PictureRequest) {
 	logger := log.With().Str("callsign", request.Callsign).Type("type", request).Logger()
 	logger.Debug().Msg("handling request")
 
-	c.broadcastPicture(&logger)
+	c.broadcastPicture(&logger, true)
 }
 
-func (c *controller) broadcastPicture(logger *zerolog.Logger) {
+func (c *controller) broadcastPicture(logger *zerolog.Logger, forceBroadcast bool) {
 	count, groups := c.scope.GetPicture(conf.DefaultPictureRadius, c.coalition.Opposite(), brevity.FixedWing)
+	isPictureClean := count == 0
 	for _, group := range groups {
 		group.SetDeclaration(brevity.Hostile)
 	}
 
-	logger.Info().Int("groups", len(groups)).Int("count", count).Msg("broadcasting PICTURE")
-	c.out <- brevity.PictureResponse{Count: count, Groups: groups}
+	if c.wasLastPictureClean && isPictureClean && !forceBroadcast {
+		logger.Info().Msg("skipping PICTURE broadcast because situation has not changed since last broadcast")
+	} else {
+		logger.Info().Int("groups", len(groups)).Int("count", count).Msg("broadcasting PICTURE")
+		c.out <- brevity.PictureResponse{Count: count, Groups: groups}
+	}
 
 	c.pictureBroadcastDeadline = time.Now().Add(c.pictureBroadcastInterval)
-	logger.Info().Time("deadline", c.pictureBroadcastDeadline).Msg("extending next PICTURE broadcast time")
+	c.wasLastPictureClean = isPictureClean
+	logger.Info().Time("deadline", c.pictureBroadcastDeadline).Msg("extended next PICTURE broadcast time")
 }
diff --git a/pkg/radar/center.go b/pkg/radar/center.go
index e8f27dc..e7a9ad1 100644
--- a/pkg/radar/center.go
+++ b/pkg/radar/center.go
@@ -20,9 +20,7 @@ func shiftPointTowards(a orb.Point, b orb.Point) orb.Point {
 func (s *scope) updateCenterPoint() {
 	blue := orb.Point{}
 	red := orb.Point{}
-	itr := s.contacts.itr()
-	for itr.next() {
-		contact := itr.value()
+	for contact := range s.contacts.values() {
 		data, ok := encyclopedia.GetAircraftData(contact.Contact.ACMIName)
 		isArmed := !ok || data.ThreatRadius() > 0
 		isValid := isValidTrack(contact)
diff --git a/pkg/radar/contacts.go b/pkg/radar/contacts.go
index b54aae3..e0c8a38 100644
--- a/pkg/radar/contacts.go
+++ b/pkg/radar/contacts.go
@@ -1,6 +1,8 @@
 package radar
 
 import (
+	"iter"
+	"slices"
 	"strings"
 	"sync"
 	"time"
@@ -28,20 +30,8 @@ type contactDatabase interface {
 	// delete removes the trackfile for the given unit ID.
 	// It returns true if the trackfile was found and removed, and false otherwise.
 	delete(uint32) bool
-	// itr returns an iterator over the database.
-	itr() databaseIterator
-}
-
-// databaseIterator iterates over the contents of a contactDatabase.
-type databaseIterator interface {
-	// next advances the iterator to the next trackfile in the database.
-	// It returns false when the iterator has passed the last trackfile.
-	next() bool
-	// reset the iterator to the beginning.
-	reset()
-	// value returns the trackfile at the current position of the iterator.
-	// It should only be called after Next returns true.
-	value() *trackfiles.Trackfile
+	// values iterates over all trackfiles in the database.
+	values() iter.Seq[*trackfiles.Trackfile]
 }
 
 type database struct {
@@ -65,6 +55,7 @@ func newContactDatabase() contactDatabase {
 
 // getByCallsignAndCoalititon implements [contactDatabase.getByCallsignAndCoalititon].
 func (d *database) getByCallsignAndCoalititon(callsign string, coalition coalitions.Coalition) (string, *trackfiles.Trackfile, bool) {
+	logger := log.With().Str("callsign", callsign).Str("coalition", coalition.String()).Logger()
 	d.lock.RLock()
 	defer d.lock.RUnlock()
 
@@ -77,14 +68,14 @@ func (d *database) getByCallsignAndCoalititon(callsign string, coalition coaliti
 		for k := range d.callsignIdx[coalition] {
 			keys = append(keys, k)
 		}
-		log.Info().Str("callsign", callsign).Msg("callsign not found in index, attempting fuzzy search")
+		logger.Info().Msg("callsign not found in index, attempting fuzzy search")
 		var err error
 		foundCallsign, err = fuzz.FuzzySearchThreshold(callsign, keys, 0.63, fuzz.Levenshtein)
 		if foundCallsign == "" || err != nil {
-			log.Warn().Err(err).Str("callsign", callsign).Msg("callsign not found in index")
+			logger.Warn().Err(err).Msg("callsign not found in index")
 			return "", nil, false
 		}
-		log.Info().Str("callsign", callsign).Str("foundCallsign", foundCallsign).Msg("similar callsign found in index")
+		logger.Info().Str("foundCallsign", foundCallsign).Msg("similar callsign found in index")
 		unitId = d.callsignIdx[coalition][foundCallsign]
 	}
 	contact, ok := d.contacts[unitId]
@@ -146,55 +137,14 @@ func (d *database) delete(unitId uint32) bool {
 	return ok
 }
 
-// itr implements [contactDatabase.itr].
-func (d *database) itr() databaseIterator {
+// values implements [contactDatabase.values].
+func (d *database) values() iter.Seq[*trackfiles.Trackfile] {
 	d.lock.RLock()
 	defer d.lock.RUnlock()
 
-	// Iterate over a copy, for thread safety
-	unitIds := make([]uint32, 0, len(d.contacts))
-	copy := make(map[uint32]*trackfiles.Trackfile)
-	for unitId := range d.contacts {
-		unitIds = append(unitIds, unitId)
-		copy[unitId] = d.contacts[unitId]
-	}
-
-	return newDatabaseIterator(unitIds, func(id uint32) (*trackfiles.Trackfile, bool) {
-		contact, ok := copy[id]
-		return contact, ok
-	})
-}
-
-type iterator struct {
-	cursor  int
-	unitIds []uint32
-	getFn   func(uint32) (*trackfiles.Trackfile, bool)
-}
-
-func newDatabaseIterator(unitIds []uint32, getFn func(uint32) (*trackfiles.Trackfile, bool)) databaseIterator {
-	return &iterator{
-		cursor:  -1,
-		unitIds: unitIds,
-		getFn:   getFn,
-	}
-}
-
-// next implements [iterator.next].
-func (i *iterator) next() bool {
-	i.cursor++
-	return i.cursor < len(i.unitIds)
-}
-
-// reset implements [iterator.reset].
-func (i *iterator) reset() {
-	i.cursor = -1
-}
-
-// value implements [iterator.value].
-func (i *iterator) value() *trackfiles.Trackfile {
-	contact, ok := i.getFn(i.unitIds[i.cursor])
-	if !ok {
-		return nil
+	contacts := make([]*trackfiles.Trackfile, 0, len(d.contacts))
+	for _, contact := range d.contacts {
+		contacts = append(contacts, contact)
 	}
-	return contact
+	return slices.Values(contacts)
 }
diff --git a/pkg/radar/grouping.go b/pkg/radar/grouping.go
index 8bdc682..b7b7760 100644
--- a/pkg/radar/grouping.go
+++ b/pkg/radar/grouping.go
@@ -13,10 +13,7 @@ import (
 func (s *scope) enumerateGroups(coalition coalitions.Coalition) []*group {
 	visited := make(map[uint32]bool)
 	groups := make([]*group, 0)
-	itr := s.contacts.itr()
-	for itr.next() {
-		trackfile := itr.value()
-
+	for trackfile := range s.contacts.values() {
 		if _, ok := visited[trackfile.Contact.UnitID]; ok {
 			continue
 		}
@@ -75,9 +72,7 @@ func (s *scope) addNearbyAircraftToGroup(this *trackfiles.Trackfile, group *grou
 		}
 	}
 	spreadInterval := 5 * unit.NauticalMile
-	itr := s.contacts.itr()
-	for itr.next() {
-		other := itr.value()
+	for other := range s.contacts.values() {
 		// Skip if this one is already in the group
 		if slices.ContainsFunc(group.contacts, func(t *trackfiles.Trackfile) bool {
 			if t == nil {
diff --git a/pkg/radar/nearby.go b/pkg/radar/nearby.go
index 15216da..a402782 100644
--- a/pkg/radar/nearby.go
+++ b/pkg/radar/nearby.go
@@ -13,9 +13,7 @@ func (s *scope) findNearbyGroups(interest orb.Point, minAltitude, maxAltitude, r
 	circle := geo.NewBoundAroundPoint(interest, float64(radius.Meters()))
 	groups := make([]*group, 0)
 	visited := make(map[uint32]struct{})
-	itr := s.contacts.itr()
-	for itr.next() {
-		trackfile := itr.value()
+	for trackfile := range s.contacts.values() {
 		if _, ok := visited[trackfile.Contact.UnitID]; ok {
 			continue
 		}
diff --git a/pkg/radar/nearest.go b/pkg/radar/nearest.go
index 967759b..e620dc1 100644
--- a/pkg/radar/nearest.go
+++ b/pkg/radar/nearest.go
@@ -27,9 +27,7 @@ func (s *scope) FindNearestTrackfile(
 ) *trackfiles.Trackfile {
 	var nearestTrackfile *trackfiles.Trackfile
 	nearestDistance := radius
-	itr := s.contacts.itr()
-	for itr.next() {
-		trackfile := itr.value()
+	for trackfile := range s.contacts.values() {
 		isMatch := s.isMatch(trackfile, coalition, filter)
 		altitude := trackfile.LastKnown().Altitude
 		isWithinAltitude := minAltitude <= altitude && altitude <= maxAltitude
@@ -130,9 +128,7 @@ func (s *scope) FindNearestGroupInSector(origin orb.Point, minAltitude, maxAltit
 	logger.Debug().Any("sector", sector).Msg("searching sector")
 	nearestDistance := unit.Length(math.MaxFloat64)
 	var nearestContact *trackfiles.Trackfile
-	itr := s.contacts.itr()
-	for itr.next() {
-		trackfile := itr.value()
+	for trackfile := range s.contacts.values() {
 		logger := logger.With().Int("unitID", int(trackfile.Contact.UnitID)).Logger()
 		isMatch := s.isMatch(trackfile, coalition, filter)
 		isWithinAltitude := minAltitude <= trackfile.LastKnown().Altitude && trackfile.LastKnown().Altitude <= maxAltitude
diff --git a/pkg/radar/radar.go b/pkg/radar/radar.go
index 62468e3..794994d 100644
--- a/pkg/radar/radar.go
+++ b/pkg/radar/radar.go
@@ -206,9 +206,7 @@ func (s *scope) handleUpdate(update sim.Updated) {
 
 // handleGarbageCollection removes trackfiles that have not been updated in a long time.
 func (s *scope) handleGarbageCollection() {
-	itr := s.contacts.itr()
-	for itr.next() {
-		trackfile := itr.value()
+	for trackfile := range s.contacts.values() {
 		logger := log.With().
 			Int("unitID", int(trackfile.Contact.UnitID)).
 			Str("name", trackfile.Contact.Name).
diff --git a/pkg/spatial/spatial.go b/pkg/spatial/spatial.go
index caffe5b..74cd6da 100644
--- a/pkg/spatial/spatial.go
+++ b/pkg/spatial/spatial.go
@@ -38,3 +38,17 @@ func PointAtBearingAndDistance(origin orb.Point, bearing bearings.Bearing, dista
 func IsZero(point orb.Point) bool {
 	return point.Equal(orb.Point{})
 }
+
+// NormalizeAltitude returns the absolute length rounded to the nearest 1000 feet, or nearest 100 feet if less than 1000 feet.
+func NormalizeAltitude(altitude unit.Length) unit.Length {
+	if altitude < 0 {
+		altitude = -altitude
+	}
+	bucketWidth := 1000 * unit.Foot
+	if altitude < bucketWidth {
+		bucketWidth = 100. * unit.Foot
+	}
+	bucket := int(math.Round(altitude.Feet() / bucketWidth.Feet()))
+	rounded := int(bucketWidth.Feet()) * bucket
+	return unit.Length(rounded) * unit.Foot
+}
diff --git a/pkg/synthesizer/speakers/piper.go b/pkg/synthesizer/speakers/piper.go
index 49c127c..a513758 100644
--- a/pkg/synthesizer/speakers/piper.go
+++ b/pkg/synthesizer/speakers/piper.go
@@ -3,6 +3,7 @@ package speakers
 import (
 	"bytes"
 	"fmt"
+	"time"
 
 	asset "github.com/amitybell/piper-asset"
 	masculine "github.com/amitybell/piper-voice-alan"
@@ -16,12 +17,13 @@ import (
 type piperSynth struct {
 	tts           *piper.TTS
 	playbackSpeed float32
+	playbackPause time.Duration
 }
 
 var _ Speaker = (*piperSynth)(nil)
 
 // NewPiperSpeaker creates a Speaker powered by Piper (https://github.com/rhasspy/piper)
-func NewPiperSpeaker(v voices.Voice, playbackSpeed float32) (Speaker, error) {
+func NewPiperSpeaker(v voices.Voice, playbackSpeed float32, playbackPause time.Duration) (Speaker, error) {
 	var a asset.Asset
 	if v == voices.MasculineVoice {
 		a = masculine.Asset
@@ -32,12 +34,12 @@ func NewPiperSpeaker(v voices.Voice, playbackSpeed float32) (Speaker, error) {
 	if err != nil {
 		return nil, fmt.Errorf("failed to create speaker: %w", err)
 	}
-	return &piperSynth{tts: tts, playbackSpeed: playbackSpeed}, nil
+	return &piperSynth{tts: tts, playbackSpeed: playbackSpeed, playbackPause: playbackPause}, nil
 }
 
 // Say implements Speaker.Say
 func (s *piperSynth) Say(text string) ([]float32, error) {
-	synthesized, err := s.tts.Synthesize(text, piper.WithSpeed(s.playbackSpeed))
+	synthesized, err := s.tts.Synthesize(text, piper.WithSpeed(s.playbackSpeed), piper.WithPause(float32(s.playbackPause.Seconds())))
 	if err != nil {
 		return nil, fmt.Errorf("failed to synthesize text: %w", err)
 	}
diff --git a/skyeye-config-example.yaml b/skyeye-config-example.yaml
new file mode 100644
index 0000000..64b6b24
--- /dev/null
+++ b/skyeye-config-example.yaml
@@ -0,0 +1,8 @@
+telemetry-address: "localhost:42674"
+srs-server-address: "localhost:5002"
+callsign: "Magic"
+auto-picture: true
+auto-picture-interval: "3m"
+threat-monitoring: true
+mandatory-threat-radius: 20
+whisper-model: ggml-large-v3.bin
\ No newline at end of file


Test patch:
diff --git a/pkg/brevity/altitude_test.go b/pkg/brevity/altitude_test.go
index f13cb8b..e4b2284 100644
--- a/pkg/brevity/altitude_test.go
+++ b/pkg/brevity/altitude_test.go
@@ -5,7 +5,6 @@ import (
 	"testing"
 
 	"github.com/martinlindhe/unit"
-	"github.com/rs/zerolog/log"
 	"github.com/stretchr/testify/assert"
 )
 
@@ -14,6 +13,22 @@ func TestStacks(t *testing.T) {
 		input    []unit.Length
 		expected []Stack
 	}{
+		{
+			input:    []unit.Length{},
+			expected: []Stack{},
+		},
+		{
+			input:    []unit.Length{unit.Foot * 40},
+			expected: []Stack{{Altitude: unit.Foot * 0, Count: 1}},
+		},
+		{
+			input:    []unit.Length{unit.Foot * 100},
+			expected: []Stack{{Altitude: unit.Foot * 100, Count: 1}},
+		},
+		{
+			input:    []unit.Length{unit.Foot * 100, unit.Foot * 12000},
+			expected: []Stack{{Altitude: unit.Foot * 12000, Count: 1}, {Altitude: unit.Foot * 100, Count: 1}},
+		},
 		{
 			input:    []unit.Length{unit.Foot * 10000, unit.Foot * 20000, unit.Foot * 30000, unit.Foot * 40000, unit.Foot * 50000},
 			expected: []Stack{{Altitude: unit.Foot * 50000, Count: 1}, {Altitude: unit.Foot * 40000, Count: 1}, {Altitude: unit.Foot * 30000, Count: 1}, {Altitude: unit.Foot * 20000, Count: 1}, {Altitude: unit.Foot * 10000, Count: 1}},
@@ -36,10 +51,8 @@ func TestStacks(t *testing.T) {
 			stacks := Stacks(test.input...)
 			for i, stack := range stacks {
 				expectedStack := test.expected[i]
-				log.Trace().Float64("expected", float64(expectedStack.Altitude)).Float64("actual", float64(stack.Altitude)).Msg("checking altitudes")
-				assert.Equal(t, expectedStack.Altitude, stack.Altitude)
-				log.Trace().Int("expected", expectedStack.Count).Int("actual", stack.Count).Msg("checking counts")
-				assert.Equal(t, expectedStack.Count, stack.Count)
+				assert.Equalf(t, expectedStack.Altitude, stack.Altitude, "expected %fft, got %fft", expectedStack.Altitude.Feet(), stack.Altitude.Feet())
+				assert.Equal(t, expectedStack.Count, stack.Count, "stack count mismatch")
 			}
 		})
 	}
diff --git a/pkg/radar/contacts_test.go b/pkg/radar/contacts_test.go
index 5d12d1e..a83d6ee 100644
--- a/pkg/radar/contacts_test.go
+++ b/pkg/radar/contacts_test.go
@@ -144,7 +144,7 @@ func TestDelete(t *testing.T) {
 	require.False(t, ok)
 }
 
-func TestItr(t *testing.T) {
+func TestValues(t *testing.T) {
 	db := newContactDatabase()
 
 	mobius := trackfiles.NewTrackfile(trackfiles.Labels{
@@ -163,29 +163,20 @@ func TestItr(t *testing.T) {
 	})
 	db.set(yellow)
 
-	itr := db.itr()
-
 	foundMobius := false
 	foundYellow := false
-	iterate := func() {
-		for itr.next() {
-			trackfile := itr.value()
-			if trackfile.Contact.UnitID == mobius.Contact.UnitID {
-				require.EqualValues(t, mobius, trackfile)
-				foundMobius = true
-			} else if trackfile.Contact.UnitID == yellow.Contact.UnitID {
-				require.EqualValues(t, yellow, trackfile)
-				foundYellow = true
-			}
+	for trackfile := range db.values() {
+		if trackfile.Contact.UnitID == mobius.Contact.UnitID {
+			require.EqualValues(t, mobius, trackfile)
+			foundMobius = true
+		} else if trackfile.Contact.UnitID == yellow.Contact.UnitID {
+			require.EqualValues(t, yellow, trackfile)
+			foundYellow = true
+		}
+		if foundMobius && foundYellow {
+			break
 		}
 	}
-	iterate()
 	require.True(t, foundMobius)
 	require.True(t, foundYellow)
-
-	itr.reset()
-
-	foundMobius = false
-	foundYellow = false
-	iterate()
 }
diff --git a/pkg/spatial/spatial_test.go b/pkg/spatial/spatial_test.go
index cb12fed..c80071b 100644
--- a/pkg/spatial/spatial_test.go
+++ b/pkg/spatial/spatial_test.go
@@ -179,3 +179,59 @@ func TestPointAtBearingAndDistance(t *testing.T) {
 		})
 	}
 }
+
+func TestNormalizeAltitude(t *testing.T) {
+	testCases := []struct {
+		input    unit.Length
+		expected unit.Length
+	}{
+		{
+			input:    -100 * unit.Foot,
+			expected: 100 * unit.Foot,
+		},
+		{
+			input:    0,
+			expected: 0,
+		},
+		{
+			input:    40 * unit.Foot,
+			expected: 0,
+		},
+		{
+			input:    100 * unit.Foot,
+			expected: 100 * unit.Foot,
+		},
+		{
+
+			input:    120 * unit.Foot,
+			expected: 100 * unit.Foot,
+		},
+		{
+			input:    200 * unit.Foot,
+			expected: 200 * unit.Foot,
+		},
+		{
+			input:    249 * unit.Foot,
+			expected: 200 * unit.Foot,
+		},
+		{
+			input:    250 * unit.Foot,
+			expected: 300 * unit.Foot,
+		},
+		{
+			input:    1234 * unit.Foot,
+			expected: 1000 * unit.Foot,
+		},
+		{
+			input:    10000 * unit.Foot,
+			expected: 10000 * unit.Foot,
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(fmt.Sprintf("%fft", test.input.Feet()), func(t *testing.T) {
+			actual := NormalizeAltitude(test.input)
+			assert.Equal(t, test.expected, actual, "expected %fft, got %fft", test.expected.Feet(), actual.Feet())
+		})
+	}
+}