Repository: dharmab/skyeye
Pull request #147 (2024-08-07 21:50:23)

Configurable TTS Piper speed


Patch:
diff --git a/cmd/skyeye/main.go b/cmd/skyeye/main.go
index 4a88784..6a48483 100644
--- a/cmd/skyeye/main.go
+++ b/cmd/skyeye/main.go
@@ -43,6 +43,7 @@ var (
 	telemetryUpdateInterval      time.Duration
 	whisperModelPath             string
 	voiceName                    string
+	playbackSpeed                string
 	enableAutomaticPicture       bool
 	automaticPictureInterval     time.Duration
 )
@@ -81,6 +82,8 @@ func init() {
 	_ = skyeye.MarkFlagRequired("whisper-model")
 	voiceFlag := NewEnum(&voiceName, "Voice", "", "feminine", "masculine")
 	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")
 
 	// Controller behavior
 	skyeye.Flags().BoolVar(&enableAutomaticPicture, "auto-picture", false, "Enable automatic PICTURE broadcasts")
@@ -147,6 +150,23 @@ func loadCoalition() (coalition coalitions.Coalition) {
 	return
 }
 
+func loadPlaybackSpeed() float32 {
+	speedMap := map[string]float32{
+		"veryslow": 1.3,
+		"slow":     1.15,
+		"standard": 1.0,
+		"fast":     0.85,
+		"veryfast": 0.7,
+	}
+	if speed, ok := speedMap[playbackSpeed]; ok {
+		log.Info().Float32("speed", speed).Msg("setting playback speed")
+		return speed
+	} else {
+		log.Info().Float32("speed", speed).Msg("Unknown playback speed, revert to default (standard)")
+		return 1.0
+	}
+}
+
 func loadWhisperModel() *whisper.Model {
 	log.Info().Str("path", whisperModelPath).Msg("loading whisper model")
 	whisperModel, err := whisper.New(whisperModelPath)
@@ -237,6 +257,7 @@ func Supervise(cmd *cobra.Command, args []string) {
 	voice := loadVoice(rando)
 	callsign := loadCallsign(rando)
 	frequency := loadFrequency()
+	playbackSpeed := loadPlaybackSpeed()
 
 	config := conf.Configuration{
 		ACMIFile:                     acmiFile,
@@ -254,6 +275,7 @@ func Supervise(cmd *cobra.Command, args []string) {
 		RadarSweepInterval:           telemetryUpdateInterval,
 		WhisperModel:                 whisperModel,
 		Voice:                        voice,
+		PlaybackSpeed:                playbackSpeed,
 	}
 
 	if enableAutomaticPicture {
diff --git a/go.mod b/go.mod
index 79d2e43..8474ea6 100644
--- a/go.mod
+++ b/go.mod
@@ -12,14 +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-20240805090620-b2bece3d75e2
+	github.com/nabbl/piper v0.0.0-20240807143155-14d7a439759f
 	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/stretchr/testify v1.8.4
+	github.com/stretchr/testify v1.9.0
 	github.com/zaf/resample v1.5.0
 	gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302
 	gotest.tools/gotestsum v1.11.0
@@ -28,7 +28,7 @@ require (
 require github.com/google/go-cmp v0.6.0 // indirect
 
 require (
-	github.com/adrg/xdg v0.4.0 // indirect
+	github.com/adrg/xdg v0.5.0 // indirect
 	github.com/amitybell/piper-bin-linux v0.0.0-20231118093037-92b3de178ad8 // indirect
 	github.com/amitybell/piper-bin-windows v0.0.0-20231118093113-cc2cef2f6b74 // indirect
 	github.com/bitfield/gotestdox v0.2.1 // indirect
@@ -40,7 +40,7 @@ require (
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
 	github.com/google/uuid v1.4.0 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
-	github.com/klauspost/compress v1.17.3 // indirect
+	github.com/klauspost/compress v1.17.9 // indirect
 	github.com/kr/pretty v0.2.0 // indirect
 	github.com/mattn/go-colorable v0.1.13 // indirect
 	github.com/mattn/go-isatty v0.0.19 // indirect
@@ -48,7 +48,7 @@ require (
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	golang.org/x/mod v0.12.0 // indirect
 	golang.org/x/sync v0.5.0 // indirect
-	golang.org/x/sys v0.14.0 // indirect
+	golang.org/x/sys v0.23.0 // indirect
 	golang.org/x/term v0.14.0 // indirect
 	golang.org/x/text v0.14.0 // indirect
 	golang.org/x/tools v0.11.0 // indirect
diff --git a/go.sum b/go.sum
index 5968602..d86a990 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,5 @@
-github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls=
-github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E=
+github.com/adrg/xdg v0.5.0 h1:dDaZvhMXatArP1NPHhnfaQUqWBLBsmx1h1HXQdMoFCY=
+github.com/adrg/xdg v0.5.0/go.mod h1:dDdY4M4DF9Rjy4kHPeNL+ilVF+p2lK8IdM9/rTSGcI4=
 github.com/amitybell/piper-asset v0.0.0-20231030194325-d36a29e3b1fd h1:4MLHn2cCVhzhPLlPO6946h1S0yk3o7Ry1831DEa5EcE=
 github.com/amitybell/piper-asset v0.0.0-20231030194325-d36a29e3b1fd/go.mod h1:MiDKnt4NenfcrsVxYAxQW0nu4zjFYQPjGzzLB5MvOz8=
 github.com/amitybell/piper-bin-linux v0.0.0-20231118093037-92b3de178ad8 h1:ZZoEErHc7pMWVXe6sRr3FZud8lU9G3kbF2IhHB2647o=
@@ -58,8 +58,8 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf
 github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
 github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
-github.com/klauspost/compress v1.17.3 h1:qkRjuerhUU1EmXLYGkSH6EZL+vPSxIrYjLNAK4slzwA=
-github.com/klauspost/compress v1.17.3/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
+github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
+github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs=
 github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
@@ -77,8 +77,8 @@ github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
 github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
 github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
-github.com/nabbl/piper v0.0.0-20240805090620-b2bece3d75e2 h1:t+Uh+Z4WcDCfRSW9AKCk93Z4va8k2cb8hhqBNLF6ksg=
-github.com/nabbl/piper v0.0.0-20240805090620-b2bece3d75e2/go.mod h1:pOucdGw44H/3xqK3Gnd7ITxhHZN/R1SpGmFAfRtCphU=
+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-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/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU=
@@ -104,9 +104,8 @@ 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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
-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/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
 github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
 github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
@@ -157,7 +156,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -167,8 +165,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
-golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
+golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
diff --git a/internal/application/app.go b/internal/application/app.go
index e3a94d8..69de00f 100644
--- a/internal/application/app.go
+++ b/internal/application/app.go
@@ -125,7 +125,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)
+	synthesizer, err := speakers.NewPiperSpeaker(config.Voice, config.PlaybackSpeed)
 	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 5443708..be2f67e 100644
--- a/internal/conf/configuration.go
+++ b/internal/conf/configuration.go
@@ -42,6 +42,8 @@ type Configuration struct {
 	WhisperModel *whisper.Model
 	// Voice is the voice used for SRS transmissions
 	Voice voices.Voice
+	// Piper playback speed (default is 1.0) - The higher the value the slower it is.
+	PlaybackSpeed float32
 	// PictureBroadcastInterval is the interval at which the controller will automatically broadcast a PICTURE.
 	PictureBroadcastInterval time.Duration
 }
@@ -53,3 +55,5 @@ var DefaultPictureRadius = 300 * unit.NauticalMile
 const DefaultMarginRadius = 3 * unit.NauticalMile
 
 var InitialTime time.Time = time.Date(1903, time.December, 17, 2, 35, 0, 0, time.UTC) // https://www.nps.gov/articles/firstflight.htm
+
+var DefaultPlaybackSpeed = 1.0
diff --git a/pkg/bearings/bearing.go b/pkg/bearings/bearing.go
index ad8a274..b8248fc 100644
--- a/pkg/bearings/bearing.go
+++ b/pkg/bearings/bearing.go
@@ -19,6 +19,8 @@ type Bearing interface {
 	True(declination unit.Angle) Bearing
 	// Magnetic bearing conversion computed with the given declination.
 	Magnetic(declination unit.Angle) Bearing
+	// Reciprocal bearing.
+	Reciprocal() Bearing
 	// IsTrue returns true if the bearing is a true bearing.
 	IsTrue() bool
 	// IsMagnetic returns true if the bearing is a magnetic bearing.
diff --git a/pkg/bearings/magnetic.go b/pkg/bearings/magnetic.go
index 1e4361d..f682f73 100644
--- a/pkg/bearings/magnetic.go
+++ b/pkg/bearings/magnetic.go
@@ -6,18 +6,18 @@ import (
 	"github.com/martinlindhe/unit"
 )
 
-// Magnetic is a magnetic bearing.
+// Magnetic is a magnetic bearing, pointing at the magnetic north pole.
 type Magnetic struct {
 	θ unit.Angle
 }
 
+var _ Bearing = &Magnetic{}
+
 // NewMagneticBearing creates a new magnetic bearing from the given value.
 func NewMagneticBearing(value unit.Angle) *Magnetic {
 	return &Magnetic{θ: normalize(value)}
 }
 
-var _ Bearing = &Magnetic{}
-
 // Value returns the magnetic bearing value.
 func (b *Magnetic) Value() unit.Angle {
 	return normalize(b.θ)
@@ -33,7 +33,7 @@ func (b *Magnetic) Degrees() float64 {
 	return b.Value().Degrees()
 }
 
-// RoundedDegrees returns the magnetic bearing rounded to the nearest degree.
+// RoundedDegrees returns the magnetic bearing in degrees, rounded to the nearest degree.
 func (b *Magnetic) RoundedDegrees() float64 {
 	return math.Round(b.Degrees())
 }
@@ -48,6 +48,11 @@ func (b *Magnetic) Magnetic(declination unit.Angle) Bearing {
 	return b
 }
 
+// Reciprocal returns a magnetic reciprocal.
+func (b *Magnetic) Reciprocal() Bearing {
+	return NewMagneticBearing(b.Value() + 180*unit.Degree)
+}
+
 // IsTrue returns false for a magnetic bearing.
 func (b *Magnetic) IsTrue() bool {
 	return false
diff --git a/pkg/bearings/true.go b/pkg/bearings/true.go
index 36fb509..21807b9 100644
--- a/pkg/bearings/true.go
+++ b/pkg/bearings/true.go
@@ -6,48 +6,64 @@ import (
 	"github.com/martinlindhe/unit"
 )
 
+// True is a bearing pointing to geographic north.
 type True struct {
 	θ unit.Angle
 }
 
 var _ Bearing = True{}
 
+// NewTrueBearing creates a new bearing from the given value.
 func NewTrueBearing(value unit.Angle) True {
 	return True{θ: normalize(value)}
 }
 
+// Value returns the bearing value.
 func (b True) Value() unit.Angle {
 	return normalize(b.θ)
 }
 
+// Rounded returns the bearing rounded to the nearest degree.
 func (b True) Rounded() unit.Angle {
 	return unit.Angle(b.RoundedDegrees()) * unit.Degree
 }
 
+// Degrees returns the bearing in degrees.
 func (b True) Degrees() float64 {
 	return b.Value().Degrees()
 }
 
+// RoundedDegrees returns the bearing in degrees, rounded to the nearest degree.
 func (b True) RoundedDegrees() float64 {
 	return math.Round(b.Degrees())
 }
 
+// True returns this bearing.
 func (b True) True(declination unit.Angle) Bearing {
 	return b
 }
 
+// Magnetic converts this true bearing to a magnetic bearing by subtracting the given declination.
 func (b True) Magnetic(declination unit.Angle) Bearing {
 	return NewMagneticBearing(b.Value() - declination)
 }
 
+// Reciprocal returns a reciprocal true bearing.
+func (b True) Reciprocal() Bearing {
+	return NewTrueBearing(b.Value() + 180*unit.Degree)
+}
+
+// IsTrue returns true for a true bearing.
 func (b True) IsTrue() bool {
 	return true
 }
 
+// IsMagnetic returns false for a true bearing.
 func (b True) IsMagnetic() bool {
 	return false
 }
 
+// String implements [Bearing.String]
 func (b True) String() string {
 	return toString(b)
 }
diff --git a/pkg/brevity/aspect.go b/pkg/brevity/aspect.go
index 645c1bc..315f425 100644
--- a/pkg/brevity/aspect.go
+++ b/pkg/brevity/aspect.go
@@ -28,23 +28,29 @@ func AspectFromAngle(bearing bearings.Bearing, track bearings.Bearing) Aspect {
 		log.Warn().Any("bearing", bearing).Any("track", track).Msg("bearing and track provided to AspectFromAngle should be magnetic")
 	}
 
-	var targetAspect unit.Angle
-	if track.Value() > bearing.Value() {
-		targetAspect = track.Value() - bearing.Magnetic(0).Value()
+	var θ float64
+	if bearing.Reciprocal().Value() > track.Value() {
+		θ = bearing.Reciprocal().Degrees() - track.Degrees()
 	} else {
-		targetAspect = bearing.Value() - track.Value()
+		θ = track.Reciprocal().Degrees() - bearing.Degrees()
 	}
+	θ = bearings.NewMagneticBearing(unit.Angle(θ) * unit.Degree).Degrees()
 
-	θ := targetAspect.Degrees()
 	switch {
-	case θ <= 30:
+	case 0 <= θ && θ <= 35:
 		return Hot
-	case θ <= 70:
+	case 35 < θ && θ <= 75:
 		return Flank
-	case θ <= 110:
+	case 75 < θ && θ <= 115:
 		return Beam
-	case θ <= 180:
+	case 115 < θ && θ <= 245:
 		return Drag
+	case 245 < θ && θ <= 285:
+		return Beam
+	case 285 < θ && θ <= 325:
+		return Flank
+	case 325 < θ && θ <= 360:
+		return Hot
 	default:
 		return UnknownAspect
 	}
diff --git a/pkg/encyclopedia/aircraft.go b/pkg/encyclopedia/aircraft.go
index cdaa0c1..a7b210f 100644
--- a/pkg/encyclopedia/aircraft.go
+++ b/pkg/encyclopedia/aircraft.go
@@ -145,7 +145,7 @@ func ah64Variants() []Aircraft {
 		map[string]string{
 			"A":        "A",
 			"D":        "D",
-			"D BLK.II": "D",
+			"D_BLK_II": "D",
 		},
 	)
 }
@@ -607,7 +607,7 @@ func tornadoVariants() []Aircraft {
 
 var aircraftData = []Aircraft{
 	{
-		ACMIShortName: "A-4E",
+		ACMIShortName: "A-4E-C",
 		tags: map[AircraftTag]bool{
 			FixedWing: true,
 		},
diff --git a/pkg/synthesizer/speakers/piper.go b/pkg/synthesizer/speakers/piper.go
index d4f6f6f..23d20ae 100644
--- a/pkg/synthesizer/speakers/piper.go
+++ b/pkg/synthesizer/speakers/piper.go
@@ -15,13 +15,14 @@ import (
 )
 
 type piperSynth struct {
-	tts *piper.TTS
+	tts           *piper.TTS
+	playbackSpeed float32
 }
 
 var _ Speaker = (*piperSynth)(nil)
 
 // NewPiperSpeaker creates a Speaker powered by Piper (https://github.com/rhasspy/piper)
-func NewPiperSpeaker(v voices.Voice) (Speaker, error) {
+func NewPiperSpeaker(v voices.Voice, playbackSpeed float32) (Speaker, error) {
 	var a asset.Asset
 	if v == voices.MasculineVoice {
 		a = masculine.Asset
@@ -32,12 +33,12 @@ func NewPiperSpeaker(v voices.Voice) (Speaker, error) {
 	if err != nil {
 		return nil, fmt.Errorf("failed to create speaker: %w", err)
 	}
-	return &piperSynth{tts: tts}, nil
+	return &piperSynth{tts: tts, playbackSpeed: playbackSpeed}, nil
 }
 
 // Say implements Speaker.Say
 func (s *piperSynth) Say(text string) ([]float32, error) {
-	synthesized, err := s.tts.Synthesize(text)
+	synthesized, err := s.tts.Synthesize(text, piper.WithSpeed(s.playbackSpeed))
 	if err != nil {
 		return nil, fmt.Errorf("failed to synthesize text: %w", err)
 	}


Test patch:
diff --git a/pkg/brevity/aspect_test.go b/pkg/brevity/aspect_test.go
new file mode 100644
index 0000000..e9081eb
--- /dev/null
+++ b/pkg/brevity/aspect_test.go
@@ -0,0 +1,413 @@
+package brevity
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/dharmab/skyeye/pkg/bearings"
+	"github.com/martinlindhe/unit"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestAspectFromAngle(t *testing.T) {
+	n := bearings.NewMagneticBearing(0 * unit.Degree)
+	nne := bearings.NewMagneticBearing(22.5 * unit.Degree)
+	ne := bearings.NewMagneticBearing(45 * unit.Degree)
+	ene := bearings.NewMagneticBearing(67.5 * unit.Degree)
+	e := bearings.NewMagneticBearing(90 * unit.Degree)
+	ese := bearings.NewMagneticBearing(112.5 * unit.Degree)
+	se := bearings.NewMagneticBearing(135 * unit.Degree)
+	sse := bearings.NewMagneticBearing(157.5 * unit.Degree)
+	s := bearings.NewMagneticBearing(180 * unit.Degree)
+	ssw := bearings.NewMagneticBearing(202.5 * unit.Degree)
+	sw := bearings.NewMagneticBearing(225 * unit.Degree)
+	wsw := bearings.NewMagneticBearing(247.5 * unit.Degree)
+	w := bearings.NewMagneticBearing(270 * unit.Degree)
+	wnw := bearings.NewMagneticBearing(292.5 * unit.Degree)
+	nw := bearings.NewMagneticBearing(315 * unit.Degree)
+	nnw := bearings.NewMagneticBearing(337.5 * unit.Degree)
+
+	testCases := []struct {
+		bearing  bearings.Bearing
+		track    bearings.Bearing
+		expected Aspect
+	}{
+		// ⇧
+		// ⬆
+		{
+			bearing:  n,
+			track:    n,
+			expected: Drag,
+		},
+		// ⬀
+		// ⬆
+		{
+			bearing:  n,
+			track:    nne,
+			expected: Drag,
+		},
+		{
+			bearing:  n,
+			track:    ne,
+			expected: Drag,
+		},
+		{
+			bearing:  n,
+			track:    ene,
+			expected: Beam,
+		},
+		// ⇨
+		// ⬆
+		{
+			bearing:  n,
+			track:    e,
+			expected: Beam,
+		},
+		// ⬂
+		// ⬆
+		{
+			bearing:  n,
+			track:    ese,
+			expected: Flank,
+		},
+		{
+			bearing:  n,
+			track:    se,
+			expected: Flank,
+		},
+		{
+			bearing:  n,
+			track:    sse,
+			expected: Hot,
+		},
+		// ⇩
+		// ⬆
+		{
+			bearing:  n,
+			track:    s,
+			expected: Hot,
+		},
+		// ⬃
+		// ⬆
+		{
+			bearing:  n,
+			track:    ssw,
+			expected: Hot,
+		},
+		{
+			bearing:  n,
+			track:    sw,
+			expected: Flank,
+		},
+		{
+			bearing:  n,
+			track:    wsw,
+			expected: Flank,
+		},
+		// ⇦
+		// ⬆
+		{
+			bearing:  n,
+			track:    w,
+			expected: Beam,
+		},
+		// ⬁
+		// ⬆
+		{
+			bearing:  n,
+			track:    wnw,
+			expected: Beam,
+		},
+		{
+			bearing:  n,
+			track:    nw,
+			expected: Drag,
+		},
+		{
+			bearing:  n,
+			track:    nnw,
+			expected: Drag,
+		},
+		// ⮕⇧
+		{
+			bearing:  e,
+			track:    n,
+			expected: Beam,
+		},
+		// ⮕⬀
+		{
+			bearing:  e,
+			track:    nne,
+			expected: Beam,
+		},
+		{
+			bearing:  e,
+			track:    ne,
+			expected: Drag,
+		},
+
+		{
+			bearing:  e,
+			track:    ene,
+			expected: Drag,
+		},
+		// ⮕⇨
+		{
+			bearing:  e,
+			track:    e,
+			expected: Drag,
+		},
+		// ⮕⬂
+		{
+			bearing:  e,
+			track:    ese,
+			expected: Drag,
+		},
+		{
+			bearing:  e,
+			track:    se,
+			expected: Drag,
+		},
+		{
+			bearing:  e,
+			track:    sse,
+			expected: Beam,
+		},
+		// ⮕⇩
+		{
+			bearing:  e,
+			track:    s,
+			expected: Beam,
+		},
+		// ⮕⬃
+		{
+			bearing:  e,
+			track:    ssw,
+			expected: Flank,
+		},
+		{
+			bearing:  e,
+			track:    sw,
+			expected: Flank,
+		},
+		{
+			bearing:  e,
+			track:    wsw,
+			expected: Hot,
+		},
+		// ⮕⇦
+		{
+			bearing:  e,
+			track:    w,
+			expected: Hot,
+		},
+		// ⮕⬁
+		{
+			bearing:  e,
+			track:    wnw,
+			expected: Hot,
+		},
+		{
+			bearing:  e,
+			track:    nw,
+			expected: Flank,
+		},
+		{
+			bearing:  e,
+			track:    nnw,
+			expected: Flank,
+		},
+		// ⬇
+		// ⇧
+		{
+			bearing:  s,
+			track:    n,
+			expected: Hot,
+		},
+		// ⬇
+		// ⬀
+		{
+			bearing:  s,
+			track:    nne,
+			expected: Hot,
+		},
+		{
+			bearing:  s,
+			track:    ne,
+			expected: Flank,
+		},
+		{
+			bearing:  s,
+			track:    ene,
+			expected: Flank,
+		},
+		// ⬇
+		// ⇨
+		{
+			bearing:  s,
+			track:    e,
+			expected: Beam,
+		},
+		// ⬇
+		// ⬂
+		{
+			bearing:  s,
+			track:    ese,
+			expected: Beam,
+		},
+		{
+			bearing:  s,
+			track:    se,
+			expected: Drag,
+		},
+		{
+			bearing:  s,
+			track:    sse,
+			expected: Drag,
+		},
+		// ⬇
+		// ⇩
+		{
+			bearing:  s,
+			track:    s,
+			expected: Drag,
+		},
+		// ⬇
+		// ⬃
+		{
+			bearing:  s,
+			track:    ssw,
+			expected: Drag,
+		},
+		{
+			bearing:  s,
+			track:    sw,
+			expected: Drag,
+		},
+		{
+			bearing:  s,
+			track:    wsw,
+			expected: Beam,
+		},
+		// ⬇
+		// ⇦
+		{
+			bearing:  s,
+			track:    w,
+			expected: Beam,
+		},
+		// ⬇
+		// ⬁
+		{
+			bearing:  s,
+			track:    wnw,
+			expected: Flank,
+		},
+		{
+			bearing:  s,
+			track:    nw,
+			expected: Flank,
+		},
+		{
+			bearing:  s,
+			track:    nnw,
+			expected: Hot,
+		},
+		// ⇧⬅
+		{
+			bearing:  w,
+			track:    n,
+			expected: Beam,
+		},
+		// ⬀⬅
+		{
+			bearing:  w,
+			track:    nne,
+			expected: Flank,
+		},
+		{
+			bearing:  w,
+			track:    ne,
+			expected: Flank,
+		},
+		{
+			bearing:  w,
+			track:    ene,
+			expected: Hot,
+		},
+		// ⇨⬅
+		{
+			bearing:  w,
+			track:    e,
+			expected: Hot,
+		},
+		// ⬂⬅
+		{
+			bearing:  w,
+			track:    ese,
+			expected: Hot,
+		},
+		{
+			bearing:  w,
+			track:    se,
+			expected: Flank,
+		},
+		{
+			bearing:  w,
+			track:    sse,
+			expected: Flank,
+		},
+		// ⇩⬅
+
+		{
+			bearing:  w,
+			track:    s,
+			expected: Beam,
+		},
+		// ⬃⬅
+		{
+			bearing:  w,
+			track:    ssw,
+			expected: Beam,
+		},
+		{
+			bearing:  w,
+			track:    sw,
+			expected: Drag,
+		},
+		{
+			bearing:  w,
+			track:    wsw,
+			expected: Drag,
+		},
+		// ⇦⬅
+		{
+			bearing:  w,
+			track:    w,
+			expected: Drag,
+		},
+		// ⬁⬅
+		{
+			bearing:  w,
+			track:    wnw,
+			expected: Drag,
+		},
+		{
+			bearing:  w,
+			track:    nw,
+			expected: Drag,
+		},
+		{
+			bearing:  w,
+			track:    nnw,
+			expected: Beam,
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(fmt.Sprintf("bearing %.1f track %.1f", test.bearing.Degrees(), test.track.Degrees()), func(t *testing.T) {
+			actual := AspectFromAngle(test.bearing, test.track)
+			assert.Equal(t, test.expected, actual)
+		})
+	}
+}