mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
horn pass
This commit is contained in:
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -8,6 +8,7 @@ type helloMessage struct {
|
||||
Media MediaConfig `json:"media"`
|
||||
CameraServo CameraServoConfig `json:"cameraServo"`
|
||||
Audio AudioConfig `json:"audio"`
|
||||
Horn HornConfig `json:"horn"`
|
||||
NightVision NightVisionConfig `json:"nightVision"`
|
||||
}
|
||||
|
||||
@@ -27,6 +28,7 @@ type inboundMessage struct {
|
||||
Media *mediaCommand `json:"media,omitempty"`
|
||||
Servo *servoPayload `json:"servo,omitempty"`
|
||||
TTS *ttsPayload `json:"tts,omitempty"`
|
||||
Horn *hornPayload `json:"horn,omitempty"`
|
||||
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
|
||||
Song *songPayload `json:"song,omitempty"`
|
||||
}
|
||||
@@ -64,6 +66,12 @@ type ttsPayload struct {
|
||||
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 {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
@@ -65,6 +65,14 @@ type AudioConfig struct {
|
||||
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 {
|
||||
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
|
||||
AudioPublishURL string `yaml:"audioPublishUrl" json:"audioPublishUrl,omitempty"`
|
||||
@@ -113,6 +121,7 @@ type Config struct {
|
||||
Media MediaConfig `yaml:"media"`
|
||||
CameraServo CameraServoConfig `yaml:"cameraServo"`
|
||||
Audio AudioConfig `yaml:"audio"`
|
||||
Horn HornConfig `yaml:"horn"`
|
||||
NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"`
|
||||
}
|
||||
|
||||
@@ -160,6 +169,12 @@ func LoadConfig(path string) (*Config, error) {
|
||||
DefaultVoice: "rms",
|
||||
DefaultPitch: 50,
|
||||
},
|
||||
Horn: HornConfig{
|
||||
Enabled: false,
|
||||
Volume: 0.25,
|
||||
SampleRate: 48000,
|
||||
Channels: 1,
|
||||
},
|
||||
NightVision: NightVisionConfig{
|
||||
Enabled: true,
|
||||
GPIOPin: 22,
|
||||
@@ -221,6 +236,7 @@ func LoadConfig(path string) (*Config, error) {
|
||||
return nil, fmt.Errorf("nightVision: %w", err)
|
||||
}
|
||||
validateAudioConfig(&cfg.Audio)
|
||||
validateHornConfig(&cfg.Horn)
|
||||
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 {
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -45,6 +45,11 @@ audio:
|
||||
defaultEngine: flite
|
||||
defaultVoice: rms
|
||||
defaultPitch: 50
|
||||
horn:
|
||||
enabled: false
|
||||
volume: 0.25
|
||||
sampleRate: 48000
|
||||
channels: 1
|
||||
nightVision:
|
||||
enabled: true
|
||||
gpioPin: 22
|
||||
|
||||
@@ -20,6 +20,7 @@ type WSClient struct {
|
||||
events chan RoverEvent
|
||||
media *MediaSupervisor
|
||||
servo *CameraServo
|
||||
horn *HornSynth
|
||||
nightVision *NightVisionLight
|
||||
log *log.Logger
|
||||
recoverMu sync.Mutex
|
||||
@@ -38,6 +39,10 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
|
||||
if cfg.Audio.TTSEnabled {
|
||||
ttsQueue = make(chan *ttsPayload, 2)
|
||||
}
|
||||
var horn *HornSynth
|
||||
if cfg.Horn.Enabled {
|
||||
horn = NewHornSynth(cfg.Horn, logger)
|
||||
}
|
||||
return &WSClient{
|
||||
cfg: cfg,
|
||||
adapter: adapter,
|
||||
@@ -45,6 +50,7 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
|
||||
events: events,
|
||||
media: media,
|
||||
servo: servo,
|
||||
horn: horn,
|
||||
nightVision: nightVision,
|
||||
log: logger,
|
||||
ttsQueue: ttsQueue,
|
||||
@@ -101,6 +107,7 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
|
||||
Media: c.cfg.Media,
|
||||
CameraServo: c.cfg.CameraServo,
|
||||
Audio: c.cfg.Audio,
|
||||
Horn: c.cfg.Horn,
|
||||
NightVision: c.cfg.NightVision,
|
||||
}
|
||||
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)
|
||||
case msg.TTS != nil:
|
||||
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:
|
||||
if c.nightVision == nil {
|
||||
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
@@ -11,8 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-CJ2tCddN.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BPUZmTmb.css">
|
||||
<script type="module" crossorigin src="/assets/index-BCh_wcG5.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B5cgjyeo.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -151,6 +151,7 @@ function getRoster() {
|
||||
media: record.meta?.media,
|
||||
cameraServo: record.meta?.cameraServo,
|
||||
audio: record.meta?.audio,
|
||||
horn: record.meta?.horn,
|
||||
nightVision: record.meta?.nightVision
|
||||
? { ...record.meta.nightVision, state: record.nightVisionState }
|
||||
: record.meta?.nightVision,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { useControlSystem } from '../controls/index.js';
|
||||
import { formatKeyLabel } from '../controls/keymapUtils.js';
|
||||
import NightVisionControl from './NightVisionControl.jsx';
|
||||
import HornControl from './HornControl.jsx';
|
||||
|
||||
const SLIDER_THROTTLE_MS = 150;
|
||||
|
||||
@@ -14,13 +15,15 @@ export default function CameraServoPanel() {
|
||||
const {
|
||||
state: { roverId, camera, keymap },
|
||||
pipeline,
|
||||
actions: { setServoAngle, nudgeServo, goServoHome, setNightVision },
|
||||
actions: { setServoAngle, nudgeServo, goServoHome, setNightVision, startHorn, stopHorn },
|
||||
} = useControlSystem();
|
||||
const config = camera?.config;
|
||||
const enabled = Boolean(roverId && camera?.enabled && config);
|
||||
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
|
||||
const nightVisionState = pipeline?.nightVisionState;
|
||||
const hornAvailable = Boolean(roverId && pipeline?.horn);
|
||||
const nightVisionKey = formatKeyLabel(keymap?.nightVisionToggle?.[0]);
|
||||
const hornKey = formatKeyLabel(keymap?.hornHonk?.[0]);
|
||||
const min = typeof config?.minAngle === 'number' ? config.minAngle : -30;
|
||||
const max = typeof config?.maxAngle === 'number' ? config.maxAngle : 30;
|
||||
const value =
|
||||
@@ -30,7 +33,7 @@ export default function CameraServoPanel() {
|
||||
? config.homeAngle
|
||||
: (min + max) / 2;
|
||||
|
||||
if (!enabled && !nightVisionAvailable) return null;
|
||||
if (!enabled && !nightVisionAvailable && !hornAvailable) return null;
|
||||
|
||||
const [pendingAngle, setPendingAngle] = useState(value);
|
||||
const throttleRef = useRef(null);
|
||||
@@ -101,6 +104,14 @@ export default function CameraServoPanel() {
|
||||
keyLabel={nightVisionKey}
|
||||
/>
|
||||
)}
|
||||
{hornAvailable && (
|
||||
<HornControl
|
||||
disabled={!roverId}
|
||||
onStart={startHorn}
|
||||
onStop={stopHorn}
|
||||
keyLabel={hornKey}
|
||||
/>
|
||||
)}
|
||||
{enabled && (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-sm text-slate-300">
|
||||
|
||||
@@ -7,6 +7,7 @@ import TopDownMap from './TopDownMap.jsx';
|
||||
import RoverRoster from './RoverRoster.jsx';
|
||||
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
|
||||
import NightVisionControl from './NightVisionControl.jsx';
|
||||
import HornControl from './HornControl.jsx';
|
||||
|
||||
export function RoverRosterPanel({ title = 'Rovers' }) {
|
||||
const { session, requestControl } = useSession();
|
||||
@@ -76,13 +77,14 @@ export function InlineCameraTilt({ keymap }) {
|
||||
const {
|
||||
state: { roverId, camera },
|
||||
pipeline,
|
||||
actions: { setServoAngle, setNightVision },
|
||||
actions: { setServoAngle, setNightVision, startHorn, stopHorn },
|
||||
} = useControlSystem();
|
||||
|
||||
const config = camera?.config;
|
||||
const enabled = Boolean(roverId && camera?.enabled && config);
|
||||
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
|
||||
const nightVisionState = pipeline?.nightVisionState;
|
||||
const hornAvailable = Boolean(roverId && pipeline?.horn);
|
||||
const min = typeof config?.minAngle === 'number' ? config.minAngle : -30;
|
||||
const max = typeof config?.maxAngle === 'number' ? config.maxAngle : 30;
|
||||
const value =
|
||||
@@ -113,10 +115,11 @@ export function InlineCameraTilt({ keymap }) {
|
||||
draggingRef.current = false;
|
||||
};
|
||||
|
||||
if (!enabled && !nightVisionAvailable) return null;
|
||||
if (!enabled && !nightVisionAvailable && !hornAvailable) return null;
|
||||
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
|
||||
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
|
||||
const nightVisionLabel = formatKeyLabel(keymap?.nightVisionToggle?.[0]);
|
||||
const hornLabel = formatKeyLabel(keymap?.hornHonk?.[0]);
|
||||
|
||||
return (
|
||||
<div className="surface space-y-0.5 p-0 text-sm text-slate-200">
|
||||
@@ -128,6 +131,9 @@ export function InlineCameraTilt({ keymap }) {
|
||||
keyLabel={nightVisionLabel}
|
||||
/>
|
||||
)}
|
||||
{hornAvailable && (
|
||||
<HornControl disabled={!roverId} onStart={startHorn} onStop={stopHorn} keyLabel={hornLabel} />
|
||||
)}
|
||||
{enabled && (
|
||||
<div className="space-y-0.5 px-1 py-1">
|
||||
<div className="flex items-center justify-between text-xs text-slate-300">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ const KEY_ACTIONS = [
|
||||
{ id: 'cameraUp', label: 'Camera Up', group: 'Camera' },
|
||||
{ id: 'cameraDown', label: 'Camera Down', 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: 'dockMacro', label: 'Dock Macro', group: 'Macros' },
|
||||
{ id: 'chatFocus', label: 'Toggle Chat', group: 'Chat' },
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useControlSystem } from '../controls/index.js';
|
||||
import { clampUnit } from '../controls/controlMath.js';
|
||||
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
|
||||
import NightVisionControl from './NightVisionControl.jsx';
|
||||
import HornControl from './HornControl.jsx';
|
||||
|
||||
const SOURCE = 'mobile-joystick';
|
||||
const JOYSTICK_RADIUS = 80;
|
||||
@@ -135,7 +136,7 @@ function MobileJoystickPanel({ layout }) {
|
||||
const {
|
||||
state: { roverId, camera },
|
||||
pipeline,
|
||||
actions: { setDriveVector, registerInputState, setServoAngle, setNightVision },
|
||||
actions: { setDriveVector, registerInputState, setServoAngle, setNightVision, startHorn, stopHorn },
|
||||
} = useControlSystem();
|
||||
const driveDockState = useDriveDockState(roverId);
|
||||
const dockedNotDriving = driveDockState.docked && !driveDockState.driving;
|
||||
@@ -144,6 +145,7 @@ function MobileJoystickPanel({ layout }) {
|
||||
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig);
|
||||
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
|
||||
const nightVisionState = pipeline?.nightVisionState;
|
||||
const hornAvailable = Boolean(roverId && pipeline?.horn);
|
||||
const cameraMin = typeof cameraConfig?.minAngle === 'number' ? cameraConfig.minAngle : -45;
|
||||
const cameraMax = typeof cameraConfig?.maxAngle === 'number' ? cameraConfig.maxAngle : 45;
|
||||
const cameraValue =
|
||||
@@ -223,6 +225,9 @@ function MobileJoystickPanel({ layout }) {
|
||||
onToggle={handleNightVisionToggle}
|
||||
/>
|
||||
)}
|
||||
{hornAvailable && (
|
||||
<HornControl disabled={disabled} onStart={startHorn} onStop={stopHorn} />
|
||||
)}
|
||||
{cameraEnabled && (
|
||||
<div className="bg-zinc-950 p-0.5 text-xs">
|
||||
<div className="flex items-center justify-between text-[0.75rem] text-slate-400">
|
||||
|
||||
@@ -8,6 +8,7 @@ import OvercurrentLimiterPanel from './OvercurrentLimiterPanel.jsx';
|
||||
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './Tabs.jsx';
|
||||
import SessionSnapshot from './SessionSnapshot.jsx';
|
||||
import SocketLogPanel from './SocketLogPanel.jsx';
|
||||
import HornSettings from './HornSettings.jsx';
|
||||
import { useHudMapSetting } from '../hooks/useHudMapSetting.js';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { useSocket } from '../context/SocketContext.jsx';
|
||||
@@ -67,6 +68,7 @@ export default function SettingsPanel() {
|
||||
<TabPanel id="keybindings">
|
||||
<div className="space-y-0.5">
|
||||
<KeymapSettings />
|
||||
<HornSettings />
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel id="controller">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DEFAULT_KEYMAP, DEFAULT_MACROS, SONG_DEFAULT_NOTE } from './constants.j
|
||||
import { canonicalizeKeyInput } from './keymapUtils.js';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { HORN_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
||||
import {
|
||||
applyAuxOvercurrentScale,
|
||||
applyDriveOvercurrentScale,
|
||||
@@ -36,6 +37,7 @@ export function ControlSystemProvider({ children }) {
|
||||
value: controlSettings,
|
||||
save: saveControlSettings,
|
||||
} = useSettingsNamespace('controls', { keymap: DEFAULT_KEYMAP, macros: DEFAULT_MACROS });
|
||||
const { value: hornSettings } = useSettingsNamespace('horn', HORN_SETTINGS_DEFAULTS);
|
||||
const { session, homeAssistantSetState } = useSession();
|
||||
const roverId = session?.assignment?.roverId ?? null;
|
||||
const overcurrentLimiter = useOvercurrentLimiter(roverId);
|
||||
@@ -346,6 +348,31 @@ export function ControlSystemProvider({ children }) {
|
||||
[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) => {
|
||||
dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
|
||||
}, []);
|
||||
@@ -374,6 +401,8 @@ export function ControlSystemProvider({ children }) {
|
||||
registerInputState,
|
||||
setSongNote,
|
||||
sendSong,
|
||||
startHorn,
|
||||
stopHorn,
|
||||
},
|
||||
}),
|
||||
[
|
||||
@@ -397,6 +426,8 @@ export function ControlSystemProvider({ children }) {
|
||||
registerInputState,
|
||||
setSongNote,
|
||||
sendSong,
|
||||
startHorn,
|
||||
stopHorn,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -32,6 +32,11 @@ export function useCommandPipeline(options = {}) {
|
||||
return rosterEntry.nightVision;
|
||||
}, [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 emitCommand = useCallback(
|
||||
@@ -172,6 +177,18 @@ export function useCommandPipeline(options = {}) {
|
||||
[emitCommand, nightVision, roverId],
|
||||
);
|
||||
|
||||
const sendHorn = useCallback(
|
||||
(payload) => {
|
||||
if (!roverId) return null;
|
||||
emitCommand({
|
||||
type: 'horn',
|
||||
data: { horn: payload },
|
||||
});
|
||||
return payload;
|
||||
},
|
||||
[emitCommand, roverId],
|
||||
);
|
||||
|
||||
const sendSong = useCallback(
|
||||
(notes = [], options = {}) => {
|
||||
if (!roverId) return null;
|
||||
@@ -205,6 +222,7 @@ export function useCommandPipeline(options = {}) {
|
||||
servoConfig,
|
||||
nightVision,
|
||||
nightVisionState,
|
||||
horn,
|
||||
emitCommand,
|
||||
enableSensorStream,
|
||||
sendDriveDirect,
|
||||
@@ -212,6 +230,7 @@ export function useCommandPipeline(options = {}) {
|
||||
sendServoAngle,
|
||||
sendOiCommand,
|
||||
sendNightVision,
|
||||
sendHorn,
|
||||
sendSong,
|
||||
runMacroSteps,
|
||||
}),
|
||||
@@ -221,6 +240,7 @@ export function useCommandPipeline(options = {}) {
|
||||
servoConfig,
|
||||
nightVision,
|
||||
nightVisionState,
|
||||
horn,
|
||||
emitCommand,
|
||||
enableSensorStream,
|
||||
sendDriveDirect,
|
||||
@@ -228,6 +248,7 @@ export function useCommandPipeline(options = {}) {
|
||||
sendServoAngle,
|
||||
sendOiCommand,
|
||||
sendNightVision,
|
||||
sendHorn,
|
||||
runMacroSteps,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -42,6 +42,7 @@ export const DEFAULT_KEYMAP = {
|
||||
cameraUp: ['u'],
|
||||
cameraDown: ['j'],
|
||||
nightVisionToggle: ['e'],
|
||||
hornHonk: ['h'],
|
||||
driveMacro: ['f'],
|
||||
dockMacro: ['g'],
|
||||
chatFocus: ['enter'],
|
||||
|
||||
@@ -126,6 +126,8 @@ export default function KeyboardInputManager() {
|
||||
stopAllMotion,
|
||||
registerInputState,
|
||||
toggleNightVision,
|
||||
startHorn,
|
||||
stopHorn,
|
||||
setSongNote,
|
||||
sendSong,
|
||||
},
|
||||
@@ -174,6 +176,7 @@ export default function KeyboardInputManager() {
|
||||
const lastAuxRef = useRef(ZERO_AUX);
|
||||
const servoIntervalRef = useRef(null);
|
||||
const songIntervalRef = useRef(null);
|
||||
const hornActiveRef = useRef(false);
|
||||
|
||||
const driveFromKeys = useCallback(() => {
|
||||
const tokensSnapshot = new Set(activeTokensRef.current);
|
||||
@@ -299,9 +302,13 @@ export default function KeyboardInputManager() {
|
||||
lastAuxRef.current = ZERO_AUX;
|
||||
stopServoLoop();
|
||||
stopSongLoop();
|
||||
if (hornActiveRef.current) {
|
||||
hornActiveRef.current = false;
|
||||
stopHorn();
|
||||
}
|
||||
stopAllMotion();
|
||||
registerInputState(SOURCE, { keys: [], vector: ZERO_VECTOR, aux: ZERO_AUX });
|
||||
}, [registerInputState, stopAllMotion, stopServoLoop, stopSongLoop]);
|
||||
}, [registerInputState, stopAllMotion, stopHorn, stopServoLoop, stopSongLoop]);
|
||||
|
||||
const triggerHomeAssistantCycle = useCallback(
|
||||
(targetState) => {
|
||||
@@ -364,6 +371,11 @@ export default function KeyboardInputManager() {
|
||||
runMacro('seek-dock');
|
||||
} else if (newlyPressed.some((token) => keymap.nightVisionToggle?.has(token))) {
|
||||
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))) {
|
||||
triggerHomeAssistantCycle('on');
|
||||
} else if (newlyPressed.some((token) => keymap.homeAssistantOff?.has(token))) {
|
||||
@@ -379,6 +391,10 @@ export default function KeyboardInputManager() {
|
||||
function handleKeyUp(event) {
|
||||
const tokens = tokensForEvent(event);
|
||||
tokens.forEach((token) => activeTokensRef.current.delete(token));
|
||||
if (hornActiveRef.current && !bindingActive(keymap.hornHonk, activeTokensRef.current)) {
|
||||
hornActiveRef.current = false;
|
||||
stopHorn();
|
||||
}
|
||||
ensureServoLoop();
|
||||
ensureSongLoop();
|
||||
driveFromKeys();
|
||||
@@ -407,11 +423,14 @@ export default function KeyboardInputManager() {
|
||||
keymap.chatFocus,
|
||||
keymap.dockMacro,
|
||||
keymap.driveMacro,
|
||||
keymap.hornHonk,
|
||||
resetAll,
|
||||
runMacro,
|
||||
setMode,
|
||||
stopAllMotion,
|
||||
stopSongLoop,
|
||||
startHorn,
|
||||
stopHorn,
|
||||
triggerHomeAssistantCycle,
|
||||
]);
|
||||
|
||||
|
||||
@@ -85,3 +85,8 @@ export const GAMEPAD_SETTINGS_DEFAULTS = {
|
||||
profile: GAMEPAD_PROFILE_DEFAULT,
|
||||
},
|
||||
};
|
||||
|
||||
export const HORN_SETTINGS_DEFAULTS = {
|
||||
waveform: 'saw',
|
||||
freqs: [440, 550, 660, 0],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user