horn pass

This commit is contained in:
legop3
2026-01-31 13:44:35 -05:00
parent 92ab763b27
commit b7d636c70a
26 changed files with 676 additions and 128 deletions
BIN
View File
Binary file not shown.
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+8
View File
@@ -8,6 +8,7 @@ type helloMessage struct {
Media MediaConfig `json:"media"` Media MediaConfig `json:"media"`
CameraServo CameraServoConfig `json:"cameraServo"` CameraServo CameraServoConfig `json:"cameraServo"`
Audio AudioConfig `json:"audio"` Audio AudioConfig `json:"audio"`
Horn HornConfig `json:"horn"`
NightVision NightVisionConfig `json:"nightVision"` NightVision NightVisionConfig `json:"nightVision"`
} }
@@ -27,6 +28,7 @@ type inboundMessage struct {
Media *mediaCommand `json:"media,omitempty"` Media *mediaCommand `json:"media,omitempty"`
Servo *servoPayload `json:"servo,omitempty"` Servo *servoPayload `json:"servo,omitempty"`
TTS *ttsPayload `json:"tts,omitempty"` TTS *ttsPayload `json:"tts,omitempty"`
Horn *hornPayload `json:"horn,omitempty"`
NightVision *nightVisionPayload `json:"nightVision,omitempty"` NightVision *nightVisionPayload `json:"nightVision,omitempty"`
Song *songPayload `json:"song,omitempty"` Song *songPayload `json:"song,omitempty"`
} }
@@ -64,6 +66,12 @@ type ttsPayload struct {
Speak bool `json:"speak,omitempty"` Speak bool `json:"speak,omitempty"`
} }
type hornPayload struct {
Action string `json:"action"`
Waveform string `json:"waveform,omitempty"`
Freqs []float64 `json:"freqs,omitempty"`
}
type nightVisionPayload struct { type nightVisionPayload struct {
Action string `json:"action"` Action string `json:"action"`
} }
+31
View File
@@ -65,6 +65,14 @@ type AudioConfig struct {
DefaultPitch int `yaml:"defaultPitch" json:"defaultPitch,omitempty"` DefaultPitch int `yaml:"defaultPitch" json:"defaultPitch,omitempty"`
} }
type HornConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Volume float64 `yaml:"volume" json:"-"`
SampleRate int `yaml:"sampleRate" json:"-"`
Channels int `yaml:"channels" json:"-"`
Device string `yaml:"device" json:"-"`
}
type MediaConfig struct { type MediaConfig struct {
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"` PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
AudioPublishURL string `yaml:"audioPublishUrl" json:"audioPublishUrl,omitempty"` AudioPublishURL string `yaml:"audioPublishUrl" json:"audioPublishUrl,omitempty"`
@@ -113,6 +121,7 @@ type Config struct {
Media MediaConfig `yaml:"media"` Media MediaConfig `yaml:"media"`
CameraServo CameraServoConfig `yaml:"cameraServo"` CameraServo CameraServoConfig `yaml:"cameraServo"`
Audio AudioConfig `yaml:"audio"` Audio AudioConfig `yaml:"audio"`
Horn HornConfig `yaml:"horn"`
NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"` NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"`
} }
@@ -160,6 +169,12 @@ func LoadConfig(path string) (*Config, error) {
DefaultVoice: "rms", DefaultVoice: "rms",
DefaultPitch: 50, DefaultPitch: 50,
}, },
Horn: HornConfig{
Enabled: false,
Volume: 0.25,
SampleRate: 48000,
Channels: 1,
},
NightVision: NightVisionConfig{ NightVision: NightVisionConfig{
Enabled: true, Enabled: true,
GPIOPin: 22, GPIOPin: 22,
@@ -221,6 +236,7 @@ func LoadConfig(path string) (*Config, error) {
return nil, fmt.Errorf("nightVision: %w", err) return nil, fmt.Errorf("nightVision: %w", err)
} }
validateAudioConfig(&cfg.Audio) validateAudioConfig(&cfg.Audio)
validateHornConfig(&cfg.Horn)
return &cfg, nil return &cfg, nil
} }
@@ -291,6 +307,21 @@ func validateAudioConfig(cfg *AudioConfig) {
} }
} }
func validateHornConfig(cfg *HornConfig) {
if cfg.Volume <= 0 {
cfg.Volume = 0.25
}
if cfg.Volume > 1 {
cfg.Volume = 1
}
if cfg.SampleRate <= 0 {
cfg.SampleRate = 48000
}
if cfg.Channels <= 0 {
cfg.Channels = 1
}
}
func validateNightVisionConfig(cfg *NightVisionConfig) error { func validateNightVisionConfig(cfg *NightVisionConfig) error {
if !cfg.Enabled { if !cfg.Enabled {
return nil return nil
+232
View File
@@ -0,0 +1,232 @@
package roverd
import (
"bufio"
"encoding/binary"
"fmt"
"log"
"math"
"os/exec"
"strings"
"sync"
"time"
)
const (
hornAttack = 20 * time.Millisecond
hornRelease = 60 * time.Millisecond
)
type HornSynth struct {
cfg HornConfig
log *log.Logger
mu sync.Mutex
stop chan struct{}
done chan struct{}
active bool
}
func NewHornSynth(cfg HornConfig, logger *log.Logger) *HornSynth {
return &HornSynth{
cfg: cfg,
log: logger,
}
}
func (h *HornSynth) HandlePayload(payload *hornPayload) error {
if payload == nil {
return fmt.Errorf("horn payload required")
}
action := strings.ToLower(strings.TrimSpace(payload.Action))
switch action {
case "start", "on", "honk":
waveform := strings.ToLower(strings.TrimSpace(payload.Waveform))
if waveform != "sine" && waveform != "saw" {
waveform = "saw"
}
freqs := sanitizeHornFreqs(payload.Freqs)
if len(freqs) == 0 {
h.Stop()
return nil
}
return h.Start(waveform, freqs)
case "stop", "off":
h.Stop()
return nil
default:
return fmt.Errorf("unsupported horn action: %s", payload.Action)
}
}
func (h *HornSynth) Start(waveform string, freqs []float64) error {
h.mu.Lock()
h.stopLocked()
stop := make(chan struct{})
done := make(chan struct{})
h.stop = stop
h.done = done
h.active = true
h.mu.Unlock()
go h.run(waveform, freqs, stop, done)
return nil
}
func (h *HornSynth) Stop() {
h.mu.Lock()
h.stopLocked()
h.mu.Unlock()
}
func (h *HornSynth) stopLocked() {
if !h.active {
return
}
if h.stop != nil {
close(h.stop)
}
if h.done != nil {
<-h.done
}
h.stop = nil
h.done = nil
h.active = false
}
func (h *HornSynth) run(waveform string, freqs []float64, stop <-chan struct{}, done chan<- struct{}) {
defer close(done)
rate := h.cfg.SampleRate
if rate <= 0 {
rate = 48000
}
channels := h.cfg.Channels
if channels <= 0 {
channels = 1
}
volume := h.cfg.Volume
if volume <= 0 {
volume = 0.25
}
if volume > 1 {
volume = 1
}
args := []string{"-q", "-f", "S16_LE", "-c", fmt.Sprintf("%d", channels), "-r", fmt.Sprintf("%d", rate), "-t", "raw"}
if h.cfg.Device != "" {
args = append(args, "-D", h.cfg.Device)
}
cmd := exec.Command("aplay", args...)
stdin, err := cmd.StdinPipe()
if err != nil {
h.log.Printf("horn: aplay stdin failed: %v", err)
return
}
if err := cmd.Start(); err != nil {
h.log.Printf("horn: aplay start failed: %v", err)
_ = stdin.Close()
return
}
writer := bufio.NewWriterSize(stdin, 32*1024)
if err := h.synthLoop(writer, waveform, freqs, rate, channels, volume, stop); err != nil {
h.log.Printf("horn: synth failed: %v", err)
}
_ = writer.Flush()
_ = stdin.Close()
if err := cmd.Wait(); err != nil {
h.log.Printf("horn: aplay exit: %v", err)
}
}
func (h *HornSynth) synthLoop(writer *bufio.Writer, waveform string, freqs []float64, rate, channels int, volume float64, stop <-chan struct{}) error {
phase := make([]float64, len(freqs))
increment := make([]float64, len(freqs))
for i, f := range freqs {
increment[i] = 2 * math.Pi * f / float64(rate)
}
attackFrames := int(float64(rate) * hornAttack.Seconds())
releaseFrames := int(float64(rate) * hornRelease.Seconds())
framesPerChunk := 512
buf := make([]byte, framesPerChunk*channels*2)
scale := volume / float64(len(freqs))
stopRequested := false
releaseStart := -1
sampleIndex := 0
for {
if !stopRequested {
select {
case <-stop:
stopRequested = true
releaseStart = sampleIndex
default:
}
}
for i := 0; i < framesPerChunk; i++ {
env := 1.0
if attackFrames > 0 && sampleIndex < attackFrames {
env = float64(sampleIndex) / float64(attackFrames)
} else if stopRequested && releaseFrames > 0 {
relIndex := sampleIndex - releaseStart
if relIndex >= releaseFrames {
return nil
}
env = float64(releaseFrames-relIndex) / float64(releaseFrames)
} else if stopRequested {
return nil
}
sample := 0.0
for j := range freqs {
switch waveform {
case "sine":
sample += math.Sin(phase[j])
default:
sample += sawFromPhase(phase[j])
}
phase[j] += increment[j]
if phase[j] > 2*math.Pi {
phase[j] -= 2 * math.Pi
}
}
sample *= scale * env
if sample > 1.0 {
sample = 1.0
} else if sample < -1.0 {
sample = -1.0
}
intSample := int16(sample * math.MaxInt16)
offset := i * channels * 2
for ch := 0; ch < channels; ch++ {
binary.LittleEndian.PutUint16(buf[offset+ch*2:], uint16(intSample))
}
sampleIndex++
}
if _, err := writer.Write(buf); err != nil {
return err
}
}
}
func sanitizeHornFreqs(freqs []float64) []float64 {
if len(freqs) == 0 {
return nil
}
out := make([]float64, 0, 4)
for _, f := range freqs {
if len(out) >= 4 {
break
}
if f <= 0 {
continue
}
out = append(out, f)
}
return out
}
func sawFromPhase(phase float64) float64 {
return 2.0*(phase/(2*math.Pi)) - 1.0
}
+5
View File
@@ -45,6 +45,11 @@ audio:
defaultEngine: flite defaultEngine: flite
defaultVoice: rms defaultVoice: rms
defaultPitch: 50 defaultPitch: 50
horn:
enabled: false
volume: 0.25
sampleRate: 48000
channels: 1
nightVision: nightVision:
enabled: true enabled: true
gpioPin: 22 gpioPin: 22
+12
View File
@@ -20,6 +20,7 @@ type WSClient struct {
events chan RoverEvent events chan RoverEvent
media *MediaSupervisor media *MediaSupervisor
servo *CameraServo servo *CameraServo
horn *HornSynth
nightVision *NightVisionLight nightVision *NightVisionLight
log *log.Logger log *log.Logger
recoverMu sync.Mutex recoverMu sync.Mutex
@@ -38,6 +39,10 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
if cfg.Audio.TTSEnabled { if cfg.Audio.TTSEnabled {
ttsQueue = make(chan *ttsPayload, 2) ttsQueue = make(chan *ttsPayload, 2)
} }
var horn *HornSynth
if cfg.Horn.Enabled {
horn = NewHornSynth(cfg.Horn, logger)
}
return &WSClient{ return &WSClient{
cfg: cfg, cfg: cfg,
adapter: adapter, adapter: adapter,
@@ -45,6 +50,7 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
events: events, events: events,
media: media, media: media,
servo: servo, servo: servo,
horn: horn,
nightVision: nightVision, nightVision: nightVision,
log: logger, log: logger,
ttsQueue: ttsQueue, ttsQueue: ttsQueue,
@@ -101,6 +107,7 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
Media: c.cfg.Media, Media: c.cfg.Media,
CameraServo: c.cfg.CameraServo, CameraServo: c.cfg.CameraServo,
Audio: c.cfg.Audio, Audio: c.cfg.Audio,
Horn: c.cfg.Horn,
NightVision: c.cfg.NightVision, NightVision: c.cfg.NightVision,
} }
c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin) c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin)
@@ -180,6 +187,11 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
return c.handleServoCommand(msg.Servo) return c.handleServoCommand(msg.Servo)
case msg.TTS != nil: case msg.TTS != nil:
return c.enqueueTTS(msg.TTS) return c.enqueueTTS(msg.TTS)
case msg.Horn != nil:
if c.horn == nil {
return fmt.Errorf("horn disabled")
}
return c.horn.HandlePayload(msg.Horn)
case msg.NightVision != nil: case msg.NightVision != nil:
if c.nightVision == nil { if c.nightVision == nil {
return fmt.Errorf("night vision disabled") return fmt.Errorf("night vision disabled")
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title> <title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-CJ2tCddN.js"></script> <script type="module" crossorigin src="/assets/index-BCh_wcG5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BPUZmTmb.css"> <link rel="stylesheet" crossorigin href="/assets/index-B5cgjyeo.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+1
View File
@@ -151,6 +151,7 @@ function getRoster() {
media: record.meta?.media, media: record.meta?.media,
cameraServo: record.meta?.cameraServo, cameraServo: record.meta?.cameraServo,
audio: record.meta?.audio, audio: record.meta?.audio,
horn: record.meta?.horn,
nightVision: record.meta?.nightVision nightVision: record.meta?.nightVision
? { ...record.meta.nightVision, state: record.nightVisionState } ? { ...record.meta.nightVision, state: record.nightVisionState }
: record.meta?.nightVision, : record.meta?.nightVision,
+13 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { useControlSystem } from '../controls/index.js'; import { useControlSystem } from '../controls/index.js';
import { formatKeyLabel } from '../controls/keymapUtils.js'; import { formatKeyLabel } from '../controls/keymapUtils.js';
import NightVisionControl from './NightVisionControl.jsx'; import NightVisionControl from './NightVisionControl.jsx';
import HornControl from './HornControl.jsx';
const SLIDER_THROTTLE_MS = 150; const SLIDER_THROTTLE_MS = 150;
@@ -14,13 +15,15 @@ export default function CameraServoPanel() {
const { const {
state: { roverId, camera, keymap }, state: { roverId, camera, keymap },
pipeline, pipeline,
actions: { setServoAngle, nudgeServo, goServoHome, setNightVision }, actions: { setServoAngle, nudgeServo, goServoHome, setNightVision, startHorn, stopHorn },
} = useControlSystem(); } = useControlSystem();
const config = camera?.config; const config = camera?.config;
const enabled = Boolean(roverId && camera?.enabled && config); const enabled = Boolean(roverId && camera?.enabled && config);
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision); const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
const nightVisionState = pipeline?.nightVisionState; const nightVisionState = pipeline?.nightVisionState;
const hornAvailable = Boolean(roverId && pipeline?.horn);
const nightVisionKey = formatKeyLabel(keymap?.nightVisionToggle?.[0]); const nightVisionKey = formatKeyLabel(keymap?.nightVisionToggle?.[0]);
const hornKey = formatKeyLabel(keymap?.hornHonk?.[0]);
const min = typeof config?.minAngle === 'number' ? config.minAngle : -30; const min = typeof config?.minAngle === 'number' ? config.minAngle : -30;
const max = typeof config?.maxAngle === 'number' ? config.maxAngle : 30; const max = typeof config?.maxAngle === 'number' ? config.maxAngle : 30;
const value = const value =
@@ -30,7 +33,7 @@ export default function CameraServoPanel() {
? config.homeAngle ? config.homeAngle
: (min + max) / 2; : (min + max) / 2;
if (!enabled && !nightVisionAvailable) return null; if (!enabled && !nightVisionAvailable && !hornAvailable) return null;
const [pendingAngle, setPendingAngle] = useState(value); const [pendingAngle, setPendingAngle] = useState(value);
const throttleRef = useRef(null); const throttleRef = useRef(null);
@@ -101,6 +104,14 @@ export default function CameraServoPanel() {
keyLabel={nightVisionKey} keyLabel={nightVisionKey}
/> />
)} )}
{hornAvailable && (
<HornControl
disabled={!roverId}
onStart={startHorn}
onStop={stopHorn}
keyLabel={hornKey}
/>
)}
{enabled && ( {enabled && (
<> <>
<div className="flex items-center justify-between text-sm text-slate-300"> <div className="flex items-center justify-between text-sm text-slate-300">
+8 -2
View File
@@ -7,6 +7,7 @@ import TopDownMap from './TopDownMap.jsx';
import RoverRoster from './RoverRoster.jsx'; import RoverRoster from './RoverRoster.jsx';
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx'; import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
import NightVisionControl from './NightVisionControl.jsx'; import NightVisionControl from './NightVisionControl.jsx';
import HornControl from './HornControl.jsx';
export function RoverRosterPanel({ title = 'Rovers' }) { export function RoverRosterPanel({ title = 'Rovers' }) {
const { session, requestControl } = useSession(); const { session, requestControl } = useSession();
@@ -76,13 +77,14 @@ export function InlineCameraTilt({ keymap }) {
const { const {
state: { roverId, camera }, state: { roverId, camera },
pipeline, pipeline,
actions: { setServoAngle, setNightVision }, actions: { setServoAngle, setNightVision, startHorn, stopHorn },
} = useControlSystem(); } = useControlSystem();
const config = camera?.config; const config = camera?.config;
const enabled = Boolean(roverId && camera?.enabled && config); const enabled = Boolean(roverId && camera?.enabled && config);
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision); const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
const nightVisionState = pipeline?.nightVisionState; const nightVisionState = pipeline?.nightVisionState;
const hornAvailable = Boolean(roverId && pipeline?.horn);
const min = typeof config?.minAngle === 'number' ? config.minAngle : -30; const min = typeof config?.minAngle === 'number' ? config.minAngle : -30;
const max = typeof config?.maxAngle === 'number' ? config.maxAngle : 30; const max = typeof config?.maxAngle === 'number' ? config.maxAngle : 30;
const value = const value =
@@ -113,10 +115,11 @@ export function InlineCameraTilt({ keymap }) {
draggingRef.current = false; draggingRef.current = false;
}; };
if (!enabled && !nightVisionAvailable) return null; if (!enabled && !nightVisionAvailable && !hornAvailable) return null;
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]); const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]); const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
const nightVisionLabel = formatKeyLabel(keymap?.nightVisionToggle?.[0]); const nightVisionLabel = formatKeyLabel(keymap?.nightVisionToggle?.[0]);
const hornLabel = formatKeyLabel(keymap?.hornHonk?.[0]);
return ( return (
<div className="surface space-y-0.5 p-0 text-sm text-slate-200"> <div className="surface space-y-0.5 p-0 text-sm text-slate-200">
@@ -128,6 +131,9 @@ export function InlineCameraTilt({ keymap }) {
keyLabel={nightVisionLabel} keyLabel={nightVisionLabel}
/> />
)} )}
{hornAvailable && (
<HornControl disabled={!roverId} onStart={startHorn} onStop={stopHorn} keyLabel={hornLabel} />
)}
{enabled && ( {enabled && (
<div className="space-y-0.5 px-1 py-1"> <div className="space-y-0.5 px-1 py-1">
<div className="flex items-center justify-between text-xs text-slate-300"> <div className="flex items-center justify-between text-xs text-slate-300">
+72
View File
@@ -0,0 +1,72 @@
import { useMemo, useState } from 'react';
export default function HornControl({
disabled,
onStart,
onStop,
keyLabel,
className = '',
}) {
const [pressed, setPressed] = useState(false);
const buttonClasses = useMemo(() => {
const base =
'group flex w-full items-center justify-between rounded-xl border-2 px-1 py-0.75 text-xs font-semibold';
const active = 'border-rose-300/70 bg-rose-800 text-rose-50 hover:bg-rose-700';
const inactive = 'border-amber-300/70 bg-amber-900 text-amber-50 hover:bg-amber-800';
return [base, pressed ? active : inactive, 'disabled:opacity-50', className]
.filter(Boolean)
.join(' ');
}, [className, pressed]);
const start = () => {
if (disabled) return;
if (!pressed) {
setPressed(true);
onStart?.();
}
};
const stop = () => {
if (pressed) {
setPressed(false);
onStop?.();
}
};
return (
<button
type="button"
onPointerDown={(event) => {
event.preventDefault();
start();
}}
onPointerUp={(event) => {
event.preventDefault();
stop();
}}
onPointerLeave={stop}
onPointerCancel={stop}
onBlur={stop}
disabled={disabled}
aria-pressed={pressed}
className={buttonClasses}
>
<span className="flex items-center gap-0.5">
<span>Horn</span>
{keyLabel ? (
<span className="rounded bg-slate-800 px-1 py-0.5 text-[0.6rem] font-semibold text-slate-200">
{keyLabel}
</span>
) : null}
</span>
<span
className={`rounded px-1 py-0.5 text-[0.65rem] font-semibold ${
pressed ? 'bg-rose-400 text-rose-950' : 'bg-slate-700 text-slate-200'
}`}
>
{pressed ? 'HONK' : 'Hold'}
</span>
</button>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSettingsNamespace } from '../settings/index.js';
import { HORN_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
function clampFreq(value) {
const num = Number(value);
if (!Number.isFinite(num)) return 0;
if (num <= 0) return 0;
return Math.min(5000, Math.round(num));
}
export default function HornSettings() {
const { value: hornSettings, save: saveHornSettings } = useSettingsNamespace(
'horn',
HORN_SETTINGS_DEFAULTS,
);
const [waveform, setWaveform] = useState(hornSettings?.waveform || HORN_SETTINGS_DEFAULTS.waveform);
const [freqs, setFreqs] = useState(() => {
const base = Array.isArray(hornSettings?.freqs) ? hornSettings.freqs : HORN_SETTINGS_DEFAULTS.freqs;
return [...base, 0, 0, 0, 0].slice(0, 4).map((f) => clampFreq(f));
});
useEffect(() => {
setWaveform(hornSettings?.waveform || HORN_SETTINGS_DEFAULTS.waveform);
if (Array.isArray(hornSettings?.freqs)) {
setFreqs([...hornSettings.freqs, 0, 0, 0, 0].slice(0, 4).map((f) => clampFreq(f)));
}
}, [hornSettings?.freqs, hornSettings?.waveform]);
const formattedWaveform = useMemo(
() => (waveform === 'sine' ? 'sine' : 'saw'),
[waveform],
);
const updateWaveform = useCallback(
(event) => {
const next = event.target.value === 'sine' ? 'sine' : 'saw';
setWaveform(next);
saveHornSettings((current) => ({ ...(current ?? {}), waveform: next }));
},
[saveHornSettings],
);
const updateFreq = useCallback(
(index, value) => {
setFreqs((prev) => {
const next = [...prev];
next[index] = clampFreq(value);
saveHornSettings((current) => ({ ...(current ?? {}), freqs: next }));
return next;
});
},
[saveHornSettings],
);
return (
<section className="panel-section space-y-0.5 text-sm">
<p className="text-slate-400">Horn</p>
<label className="flex items-center justify-between gap-0.5 text-slate-200">
<span>Waveform</span>
<select value={formattedWaveform} onChange={updateWaveform} className="field-input text-sm">
<option value="saw">Saw</option>
<option value="sine">Sine</option>
</select>
</label>
<div className="grid grid-cols-2 gap-0.5">
{freqs.map((freq, idx) => (
<label key={`horn-freq-${idx}`} className="surface-muted flex items-center justify-between gap-0.5">
<span className="text-[0.7rem] text-slate-300">Freq {idx + 1}</span>
<input
type="number"
min={0}
max={5000}
step={1}
value={freq}
onChange={(event) => updateFreq(idx, event.target.value)}
className="w-20 rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.75rem] font-mono text-slate-100"
/>
</label>
))}
</div>
<p className="text-xs text-slate-500">Set a frequency to 0 to disable that oscillator.</p>
</section>
);
}
+1
View File
@@ -22,6 +22,7 @@ const KEY_ACTIONS = [
{ id: 'cameraUp', label: 'Camera Up', group: 'Camera' }, { id: 'cameraUp', label: 'Camera Up', group: 'Camera' },
{ id: 'cameraDown', label: 'Camera Down', group: 'Camera' }, { id: 'cameraDown', label: 'Camera Down', group: 'Camera' },
{ id: 'nightVisionToggle', label: 'Toggle Night Vision', group: 'Camera' }, { id: 'nightVisionToggle', label: 'Toggle Night Vision', group: 'Camera' },
{ id: 'hornHonk', label: 'Horn (Hold)', group: 'Audio' },
{ id: 'driveMacro', label: 'Drive Macro', group: 'Macros' }, { id: 'driveMacro', label: 'Drive Macro', group: 'Macros' },
{ id: 'dockMacro', label: 'Dock Macro', group: 'Macros' }, { id: 'dockMacro', label: 'Dock Macro', group: 'Macros' },
{ id: 'chatFocus', label: 'Toggle Chat', group: 'Chat' }, { id: 'chatFocus', label: 'Toggle Chat', group: 'Chat' },
+6 -1
View File
@@ -3,6 +3,7 @@ import { useControlSystem } from '../controls/index.js';
import { clampUnit } from '../controls/controlMath.js'; import { clampUnit } from '../controls/controlMath.js';
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx'; import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
import NightVisionControl from './NightVisionControl.jsx'; import NightVisionControl from './NightVisionControl.jsx';
import HornControl from './HornControl.jsx';
const SOURCE = 'mobile-joystick'; const SOURCE = 'mobile-joystick';
const JOYSTICK_RADIUS = 80; const JOYSTICK_RADIUS = 80;
@@ -135,7 +136,7 @@ function MobileJoystickPanel({ layout }) {
const { const {
state: { roverId, camera }, state: { roverId, camera },
pipeline, pipeline,
actions: { setDriveVector, registerInputState, setServoAngle, setNightVision }, actions: { setDriveVector, registerInputState, setServoAngle, setNightVision, startHorn, stopHorn },
} = useControlSystem(); } = useControlSystem();
const driveDockState = useDriveDockState(roverId); const driveDockState = useDriveDockState(roverId);
const dockedNotDriving = driveDockState.docked && !driveDockState.driving; const dockedNotDriving = driveDockState.docked && !driveDockState.driving;
@@ -144,6 +145,7 @@ function MobileJoystickPanel({ layout }) {
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig); const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig);
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision); const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
const nightVisionState = pipeline?.nightVisionState; const nightVisionState = pipeline?.nightVisionState;
const hornAvailable = Boolean(roverId && pipeline?.horn);
const cameraMin = typeof cameraConfig?.minAngle === 'number' ? cameraConfig.minAngle : -45; const cameraMin = typeof cameraConfig?.minAngle === 'number' ? cameraConfig.minAngle : -45;
const cameraMax = typeof cameraConfig?.maxAngle === 'number' ? cameraConfig.maxAngle : 45; const cameraMax = typeof cameraConfig?.maxAngle === 'number' ? cameraConfig.maxAngle : 45;
const cameraValue = const cameraValue =
@@ -223,6 +225,9 @@ function MobileJoystickPanel({ layout }) {
onToggle={handleNightVisionToggle} onToggle={handleNightVisionToggle}
/> />
)} )}
{hornAvailable && (
<HornControl disabled={disabled} onStart={startHorn} onStop={stopHorn} />
)}
{cameraEnabled && ( {cameraEnabled && (
<div className="bg-zinc-950 p-0.5 text-xs"> <div className="bg-zinc-950 p-0.5 text-xs">
<div className="flex items-center justify-between text-[0.75rem] text-slate-400"> <div className="flex items-center justify-between text-[0.75rem] text-slate-400">
+2
View File
@@ -8,6 +8,7 @@ import OvercurrentLimiterPanel from './OvercurrentLimiterPanel.jsx';
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './Tabs.jsx'; import Tabs, { Tab, TabList, TabPanel, TabPanels } from './Tabs.jsx';
import SessionSnapshot from './SessionSnapshot.jsx'; import SessionSnapshot from './SessionSnapshot.jsx';
import SocketLogPanel from './SocketLogPanel.jsx'; import SocketLogPanel from './SocketLogPanel.jsx';
import HornSettings from './HornSettings.jsx';
import { useHudMapSetting } from '../hooks/useHudMapSetting.js'; import { useHudMapSetting } from '../hooks/useHudMapSetting.js';
import { useSettingsNamespace } from '../settings/index.js'; import { useSettingsNamespace } from '../settings/index.js';
import { useSocket } from '../context/SocketContext.jsx'; import { useSocket } from '../context/SocketContext.jsx';
@@ -67,6 +68,7 @@ export default function SettingsPanel() {
<TabPanel id="keybindings"> <TabPanel id="keybindings">
<div className="space-y-0.5"> <div className="space-y-0.5">
<KeymapSettings /> <KeymapSettings />
<HornSettings />
</div> </div>
</TabPanel> </TabPanel>
<TabPanel id="controller"> <TabPanel id="controller">
+31
View File
@@ -6,6 +6,7 @@ import { DEFAULT_KEYMAP, DEFAULT_MACROS, SONG_DEFAULT_NOTE } from './constants.j
import { canonicalizeKeyInput } from './keymapUtils.js'; import { canonicalizeKeyInput } from './keymapUtils.js';
import { useSettingsNamespace } from '../settings/index.js'; import { useSettingsNamespace } from '../settings/index.js';
import { useSession } from '../context/SessionContext.jsx'; import { useSession } from '../context/SessionContext.jsx';
import { HORN_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
import { import {
applyAuxOvercurrentScale, applyAuxOvercurrentScale,
applyDriveOvercurrentScale, applyDriveOvercurrentScale,
@@ -36,6 +37,7 @@ export function ControlSystemProvider({ children }) {
value: controlSettings, value: controlSettings,
save: saveControlSettings, save: saveControlSettings,
} = useSettingsNamespace('controls', { keymap: DEFAULT_KEYMAP, macros: DEFAULT_MACROS }); } = useSettingsNamespace('controls', { keymap: DEFAULT_KEYMAP, macros: DEFAULT_MACROS });
const { value: hornSettings } = useSettingsNamespace('horn', HORN_SETTINGS_DEFAULTS);
const { session, homeAssistantSetState } = useSession(); const { session, homeAssistantSetState } = useSession();
const roverId = session?.assignment?.roverId ?? null; const roverId = session?.assignment?.roverId ?? null;
const overcurrentLimiter = useOvercurrentLimiter(roverId); const overcurrentLimiter = useOvercurrentLimiter(roverId);
@@ -346,6 +348,31 @@ export function ControlSystemProvider({ children }) {
[pipeline], [pipeline],
); );
const normalizedHornSettings = useMemo(() => {
const base = hornSettings ?? HORN_SETTINGS_DEFAULTS;
const waveform = base.waveform === 'sine' ? 'sine' : 'saw';
const freqs = Array.isArray(base.freqs) ? base.freqs : HORN_SETTINGS_DEFAULTS.freqs;
const normalized = [...freqs, 0, 0, 0, 0]
.slice(0, 4)
.map((value) => {
const num = Number(value);
if (!Number.isFinite(num)) return 0;
return num <= 0 ? 0 : Math.min(5000, Math.round(num));
});
return { waveform, freqs: normalized };
}, [hornSettings]);
const startHorn = useCallback(() => {
if (!pipeline.horn) return;
pipeline.sendHorn({ action: 'start', ...normalizedHornSettings });
recordControlIntent();
}, [normalizedHornSettings, pipeline, recordControlIntent]);
const stopHorn = useCallback(() => {
if (!pipeline.horn) return;
pipeline.sendHorn({ action: 'stop' });
}, [pipeline]);
const registerInputState = useCallback((source, data) => { const registerInputState = useCallback((source, data) => {
dispatch({ type: 'control/register-input-state', payload: { source, state: data } }); dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
}, []); }, []);
@@ -374,6 +401,8 @@ export function ControlSystemProvider({ children }) {
registerInputState, registerInputState,
setSongNote, setSongNote,
sendSong, sendSong,
startHorn,
stopHorn,
}, },
}), }),
[ [
@@ -397,6 +426,8 @@ export function ControlSystemProvider({ children }) {
registerInputState, registerInputState,
setSongNote, setSongNote,
sendSong, sendSong,
startHorn,
stopHorn,
], ],
); );
+21
View File
@@ -32,6 +32,11 @@ export function useCommandPipeline(options = {}) {
return rosterEntry.nightVision; return rosterEntry.nightVision;
}, [rosterEntry]); }, [rosterEntry]);
const horn = useMemo(() => {
if (!rosterEntry?.horn || !rosterEntry.horn.enabled) return null;
return rosterEntry.horn;
}, [rosterEntry]);
const nightVisionState = useMemo(() => rosterEntry?.nightVision?.state ?? null, [rosterEntry]); const nightVisionState = useMemo(() => rosterEntry?.nightVision?.state ?? null, [rosterEntry]);
const emitCommand = useCallback( const emitCommand = useCallback(
@@ -172,6 +177,18 @@ export function useCommandPipeline(options = {}) {
[emitCommand, nightVision, roverId], [emitCommand, nightVision, roverId],
); );
const sendHorn = useCallback(
(payload) => {
if (!roverId) return null;
emitCommand({
type: 'horn',
data: { horn: payload },
});
return payload;
},
[emitCommand, roverId],
);
const sendSong = useCallback( const sendSong = useCallback(
(notes = [], options = {}) => { (notes = [], options = {}) => {
if (!roverId) return null; if (!roverId) return null;
@@ -205,6 +222,7 @@ export function useCommandPipeline(options = {}) {
servoConfig, servoConfig,
nightVision, nightVision,
nightVisionState, nightVisionState,
horn,
emitCommand, emitCommand,
enableSensorStream, enableSensorStream,
sendDriveDirect, sendDriveDirect,
@@ -212,6 +230,7 @@ export function useCommandPipeline(options = {}) {
sendServoAngle, sendServoAngle,
sendOiCommand, sendOiCommand,
sendNightVision, sendNightVision,
sendHorn,
sendSong, sendSong,
runMacroSteps, runMacroSteps,
}), }),
@@ -221,6 +240,7 @@ export function useCommandPipeline(options = {}) {
servoConfig, servoConfig,
nightVision, nightVision,
nightVisionState, nightVisionState,
horn,
emitCommand, emitCommand,
enableSensorStream, enableSensorStream,
sendDriveDirect, sendDriveDirect,
@@ -228,6 +248,7 @@ export function useCommandPipeline(options = {}) {
sendServoAngle, sendServoAngle,
sendOiCommand, sendOiCommand,
sendNightVision, sendNightVision,
sendHorn,
runMacroSteps, runMacroSteps,
], ],
); );
+1
View File
@@ -42,6 +42,7 @@ export const DEFAULT_KEYMAP = {
cameraUp: ['u'], cameraUp: ['u'],
cameraDown: ['j'], cameraDown: ['j'],
nightVisionToggle: ['e'], nightVisionToggle: ['e'],
hornHonk: ['h'],
driveMacro: ['f'], driveMacro: ['f'],
dockMacro: ['g'], dockMacro: ['g'],
chatFocus: ['enter'], chatFocus: ['enter'],
@@ -126,6 +126,8 @@ export default function KeyboardInputManager() {
stopAllMotion, stopAllMotion,
registerInputState, registerInputState,
toggleNightVision, toggleNightVision,
startHorn,
stopHorn,
setSongNote, setSongNote,
sendSong, sendSong,
}, },
@@ -174,6 +176,7 @@ export default function KeyboardInputManager() {
const lastAuxRef = useRef(ZERO_AUX); const lastAuxRef = useRef(ZERO_AUX);
const servoIntervalRef = useRef(null); const servoIntervalRef = useRef(null);
const songIntervalRef = useRef(null); const songIntervalRef = useRef(null);
const hornActiveRef = useRef(false);
const driveFromKeys = useCallback(() => { const driveFromKeys = useCallback(() => {
const tokensSnapshot = new Set(activeTokensRef.current); const tokensSnapshot = new Set(activeTokensRef.current);
@@ -299,9 +302,13 @@ export default function KeyboardInputManager() {
lastAuxRef.current = ZERO_AUX; lastAuxRef.current = ZERO_AUX;
stopServoLoop(); stopServoLoop();
stopSongLoop(); stopSongLoop();
if (hornActiveRef.current) {
hornActiveRef.current = false;
stopHorn();
}
stopAllMotion(); stopAllMotion();
registerInputState(SOURCE, { keys: [], vector: ZERO_VECTOR, aux: ZERO_AUX }); registerInputState(SOURCE, { keys: [], vector: ZERO_VECTOR, aux: ZERO_AUX });
}, [registerInputState, stopAllMotion, stopServoLoop, stopSongLoop]); }, [registerInputState, stopAllMotion, stopHorn, stopServoLoop, stopSongLoop]);
const triggerHomeAssistantCycle = useCallback( const triggerHomeAssistantCycle = useCallback(
(targetState) => { (targetState) => {
@@ -364,6 +371,11 @@ export default function KeyboardInputManager() {
runMacro('seek-dock'); runMacro('seek-dock');
} else if (newlyPressed.some((token) => keymap.nightVisionToggle?.has(token))) { } else if (newlyPressed.some((token) => keymap.nightVisionToggle?.has(token))) {
toggleNightVision(); toggleNightVision();
} else if (newlyPressed.some((token) => keymap.hornHonk?.has(token))) {
if (!hornActiveRef.current) {
hornActiveRef.current = true;
startHorn();
}
} else if (newlyPressed.some((token) => keymap.homeAssistantOn?.has(token))) { } else if (newlyPressed.some((token) => keymap.homeAssistantOn?.has(token))) {
triggerHomeAssistantCycle('on'); triggerHomeAssistantCycle('on');
} else if (newlyPressed.some((token) => keymap.homeAssistantOff?.has(token))) { } else if (newlyPressed.some((token) => keymap.homeAssistantOff?.has(token))) {
@@ -379,6 +391,10 @@ export default function KeyboardInputManager() {
function handleKeyUp(event) { function handleKeyUp(event) {
const tokens = tokensForEvent(event); const tokens = tokensForEvent(event);
tokens.forEach((token) => activeTokensRef.current.delete(token)); tokens.forEach((token) => activeTokensRef.current.delete(token));
if (hornActiveRef.current && !bindingActive(keymap.hornHonk, activeTokensRef.current)) {
hornActiveRef.current = false;
stopHorn();
}
ensureServoLoop(); ensureServoLoop();
ensureSongLoop(); ensureSongLoop();
driveFromKeys(); driveFromKeys();
@@ -407,11 +423,14 @@ export default function KeyboardInputManager() {
keymap.chatFocus, keymap.chatFocus,
keymap.dockMacro, keymap.dockMacro,
keymap.driveMacro, keymap.driveMacro,
keymap.hornHonk,
resetAll, resetAll,
runMacro, runMacro,
setMode, setMode,
stopAllMotion, stopAllMotion,
stopSongLoop, stopSongLoop,
startHorn,
stopHorn,
triggerHomeAssistantCycle, triggerHomeAssistantCycle,
]); ]);
+5
View File
@@ -85,3 +85,8 @@ export const GAMEPAD_SETTINGS_DEFAULTS = {
profile: GAMEPAD_PROFILE_DEFAULT, profile: GAMEPAD_PROFILE_DEFAULT,
}, },
}; };
export const HORN_SETTINGS_DEFAULTS = {
waveform: 'saw',
freqs: [440, 550, 660, 0],
};