mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
audio
This commit is contained in:
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
+25
-3
@@ -1,10 +1,26 @@
|
|||||||
# Use the Google Voice HAT soundcard as the primary device by name (card id is "sndrpigooglevoi")
|
# Use the Google Voice HAT soundcard as the primary device by name (card id is "sndrpigooglevoi")
|
||||||
options snd_rpi_googlevoicehat_soundcard index=0
|
options snd_rpi_googlevoicehat_soundcard index=0
|
||||||
|
|
||||||
|
# Mix multiple playback clients in software with a fixed low-cost format.
|
||||||
|
pcm.dmixer {
|
||||||
|
type dmix
|
||||||
|
ipc_key 1024
|
||||||
|
ipc_perm 0666
|
||||||
|
slave {
|
||||||
|
pcm "hw:0,0"
|
||||||
|
format S16_LE
|
||||||
|
rate 16000
|
||||||
|
channels 1
|
||||||
|
period_time 0
|
||||||
|
period_size 1024
|
||||||
|
buffer_size 4096
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Software playback volume (adjust with: amixer -c0 sset 'SoftMaster' 70%)
|
# Software playback volume (adjust with: amixer -c0 sset 'SoftMaster' 70%)
|
||||||
pcm.softvol {
|
pcm.softvol {
|
||||||
type softvol
|
type softvol
|
||||||
slave.pcm "plughw:0,0"
|
slave.pcm "dmixer"
|
||||||
control {
|
control {
|
||||||
name "SoftMaster"
|
name "SoftMaster"
|
||||||
card 0
|
card 0
|
||||||
@@ -13,10 +29,16 @@ pcm.softvol {
|
|||||||
max_dB 0.0
|
max_dB 0.0
|
||||||
}
|
}
|
||||||
|
|
||||||
# Defaults: playback through softvol, capture raw on the HAT
|
# Allow clients with mismatched sample formats/rates to use the mixer.
|
||||||
|
pcm.playback {
|
||||||
|
type plug
|
||||||
|
slave.pcm "softvol"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Defaults: playback through shared mixer + softvol, capture raw on the HAT
|
||||||
pcm.!default {
|
pcm.!default {
|
||||||
type asym
|
type asym
|
||||||
playback.pcm "softvol"
|
playback.pcm "playback"
|
||||||
capture.pcm "hw:0,0"
|
capture.pcm "hw:0,0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package roverd
|
||||||
|
|
||||||
|
type AudioLevels struct {
|
||||||
|
HornGain float64
|
||||||
|
TTSGain float64
|
||||||
|
ForwardGain float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampAudioGain(v float64) float64 {
|
||||||
|
if v < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if v > 4 {
|
||||||
|
return 4
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAudioLevels(v AudioLevels) AudioLevels {
|
||||||
|
v.HornGain = clampAudioGain(v.HornGain)
|
||||||
|
v.TTSGain = clampAudioGain(v.TTSGain)
|
||||||
|
v.ForwardGain = clampAudioGain(v.ForwardGain)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) getAudioLevels() AudioLevels {
|
||||||
|
c.audioMu.RLock()
|
||||||
|
defer c.audioMu.RUnlock()
|
||||||
|
return c.audioLevels
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) setAudioLevels(next AudioLevels) {
|
||||||
|
normalized := normalizeAudioLevels(next)
|
||||||
|
c.audioMu.Lock()
|
||||||
|
c.audioLevels = normalized
|
||||||
|
c.audioMu.Unlock()
|
||||||
|
if c.horn != nil {
|
||||||
|
c.horn.SetGlobalGain(normalized.HornGain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) handleAudioLevels(payload *audioLevelsPayload) error {
|
||||||
|
if payload == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
levels := c.getAudioLevels()
|
||||||
|
if payload.HornGain != nil {
|
||||||
|
levels.HornGain = clampAudioGain(*payload.HornGain)
|
||||||
|
}
|
||||||
|
if payload.TTSGain != nil {
|
||||||
|
levels.TTSGain = clampAudioGain(*payload.TTSGain)
|
||||||
|
}
|
||||||
|
if payload.ForwardGain != nil {
|
||||||
|
levels.ForwardGain = clampAudioGain(*payload.ForwardGain)
|
||||||
|
}
|
||||||
|
c.setAudioLevels(levels)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ type inboundMessage struct {
|
|||||||
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"`
|
Horn *hornPayload `json:"horn,omitempty"`
|
||||||
|
AudioLevels *audioLevelsPayload `json:"audioLevels,omitempty"`
|
||||||
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
|
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
|
||||||
Song *songPayload `json:"song,omitempty"`
|
Song *songPayload `json:"song,omitempty"`
|
||||||
Reboot *rebootPayload `json:"reboot,omitempty"`
|
Reboot *rebootPayload `json:"reboot,omitempty"`
|
||||||
@@ -73,6 +74,12 @@ type hornPayload struct {
|
|||||||
Freqs []float64 `json:"freqs,omitempty"`
|
Freqs []float64 `json:"freqs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type audioLevelsPayload struct {
|
||||||
|
HornGain *float64 `json:"hornGain,omitempty"`
|
||||||
|
TTSGain *float64 `json:"ttsGain,omitempty"`
|
||||||
|
ForwardGain *float64 `json:"forwardGain,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type nightVisionPayload struct {
|
type nightVisionPayload struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-4
@@ -18,8 +18,9 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type HornSynth struct {
|
type HornSynth struct {
|
||||||
cfg HornConfig
|
cfg HornConfig
|
||||||
log *log.Logger
|
log *log.Logger
|
||||||
|
gain float64
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
stop chan struct{}
|
stop chan struct{}
|
||||||
@@ -29,11 +30,18 @@ type HornSynth struct {
|
|||||||
|
|
||||||
func NewHornSynth(cfg HornConfig, logger *log.Logger) *HornSynth {
|
func NewHornSynth(cfg HornConfig, logger *log.Logger) *HornSynth {
|
||||||
return &HornSynth{
|
return &HornSynth{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
log: logger,
|
log: logger,
|
||||||
|
gain: 1.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *HornSynth) SetGlobalGain(gain float64) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
h.gain = clampAudioGain(gain)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HornSynth) HandlePayload(payload *hornPayload) error {
|
func (h *HornSynth) HandlePayload(payload *hornPayload) error {
|
||||||
if payload == nil {
|
if payload == nil {
|
||||||
return fmt.Errorf("horn payload required")
|
return fmt.Errorf("horn payload required")
|
||||||
@@ -111,6 +119,10 @@ func (h *HornSynth) run(waveform string, freqs []float64, stop <-chan struct{})
|
|||||||
if volume > 1 {
|
if volume > 1 {
|
||||||
volume = 1
|
volume = 1
|
||||||
}
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
gain := h.gain
|
||||||
|
h.mu.Unlock()
|
||||||
|
volume *= gain
|
||||||
|
|
||||||
args := []string{"-q", "-f", "S16_LE", "-c", fmt.Sprintf("%d", channels), "-r", fmt.Sprintf("%d", rate), "-t", "raw"}
|
args := []string{"-q", "-f", "S16_LE", "-c", fmt.Sprintf("%d", channels), "-r", fmt.Sprintf("%d", rate), "-t", "raw"}
|
||||||
if h.cfg.Device != "" {
|
if h.cfg.Device != "" {
|
||||||
|
|||||||
+52
-5
@@ -3,6 +3,7 @@ package roverd
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -47,29 +48,75 @@ func (c *WSClient) handleTTSPayload(ctx context.Context, payload *ttsPayload) er
|
|||||||
runCtx, cancel := context.WithTimeout(ctx, 12*time.Second)
|
runCtx, cancel := context.WithTimeout(ctx, 12*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
tmp, err := os.CreateTemp("", "roverd-tts-*.wav")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tts temp file: %w", err)
|
||||||
|
}
|
||||||
|
tmpPath := tmp.Name()
|
||||||
|
_ = tmp.Close()
|
||||||
|
defer os.Remove(tmpPath)
|
||||||
|
|
||||||
|
if err := synthTTS(runCtx, engine, voice, pitch, text, tmpPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := playTTSFile(runCtx, tmpPath, c.cfg.Audio.PlaybackDevice, c.getAudioLevels().TTSGain); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func synthTTS(ctx context.Context, engine, voice string, pitch int, text, outputWavPath string) error {
|
||||||
var cmd *exec.Cmd
|
var cmd *exec.Cmd
|
||||||
switch engine {
|
switch engine {
|
||||||
case "espeak", "e":
|
case "espeak", "e":
|
||||||
args := []string{}
|
args := []string{"-w", outputWavPath}
|
||||||
if pitch > 0 {
|
if pitch > 0 {
|
||||||
args = append(args, "-p", fmt.Sprintf("%d", pitch))
|
args = append(args, "-p", fmt.Sprintf("%d", pitch))
|
||||||
}
|
}
|
||||||
args = append(args, text)
|
args = append(args, text)
|
||||||
cmd = exec.CommandContext(runCtx, "espeak", args...)
|
cmd = exec.CommandContext(ctx, "espeak", args...)
|
||||||
case "flite", "f":
|
case "flite", "f":
|
||||||
args := []string{}
|
args := []string{}
|
||||||
if voice != "" {
|
if voice != "" {
|
||||||
args = append(args, "-voice", voice)
|
args = append(args, "-voice", voice)
|
||||||
}
|
}
|
||||||
args = append(args, "-t", text)
|
args = append(args, "-t", text, "-o", outputWavPath)
|
||||||
cmd = exec.CommandContext(runCtx, "flite", args...)
|
cmd = exec.CommandContext(ctx, "flite", args...)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported tts engine: %s", engine)
|
return fmt.Errorf("unsupported tts engine: %s", engine)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("tts exec failed: %w (%s)", err, string(out))
|
return fmt.Errorf("tts synth failed: %w (%s)", err, string(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func playTTSFile(ctx context.Context, wavPath, playbackDevice string, gain float64) error {
|
||||||
|
if playbackDevice == "" {
|
||||||
|
playbackDevice = "default"
|
||||||
|
}
|
||||||
|
gain = clampAudioGain(gain)
|
||||||
|
if gain == 0 {
|
||||||
|
// Mute is an explicit value users may choose.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel", "warning",
|
||||||
|
"-i", wavPath,
|
||||||
|
"-af", fmt.Sprintf("aresample=16000,volume=%g", gain),
|
||||||
|
"-ac", "1",
|
||||||
|
"-ar", "16000",
|
||||||
|
"-f", "alsa",
|
||||||
|
playbackDevice,
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tts playback failed: %w (%s)", err, string(out))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-1
@@ -34,6 +34,8 @@ type WSClient struct {
|
|||||||
rebootT *time.Timer
|
rebootT *time.Timer
|
||||||
seekIssued bool
|
seekIssued bool
|
||||||
rebootIssued bool
|
rebootIssued bool
|
||||||
|
audioLevels AudioLevels
|
||||||
|
audioMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, nightVision *NightVisionLight, logger *log.Logger) *WSClient {
|
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, nightVision *NightVisionLight, logger *log.Logger) *WSClient {
|
||||||
@@ -45,7 +47,7 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
|
|||||||
if cfg.Horn.Enabled {
|
if cfg.Horn.Enabled {
|
||||||
horn = NewHornSynth(cfg.Horn, logger)
|
horn = NewHornSynth(cfg.Horn, logger)
|
||||||
}
|
}
|
||||||
return &WSClient{
|
client := &WSClient{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
adapter: adapter,
|
adapter: adapter,
|
||||||
sensorFrames: frames,
|
sensorFrames: frames,
|
||||||
@@ -56,7 +58,16 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
|
|||||||
nightVision: nightVision,
|
nightVision: nightVision,
|
||||||
log: logger,
|
log: logger,
|
||||||
ttsQueue: ttsQueue,
|
ttsQueue: ttsQueue,
|
||||||
|
audioLevels: AudioLevels{
|
||||||
|
HornGain: 1.0,
|
||||||
|
TTSGain: 1.0,
|
||||||
|
ForwardGain: 1.0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
if client.horn != nil {
|
||||||
|
client.horn.SetGlobalGain(client.audioLevels.HornGain)
|
||||||
|
}
|
||||||
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *WSClient) Run(ctx context.Context) error {
|
func (c *WSClient) Run(ctx context.Context) error {
|
||||||
@@ -200,6 +211,8 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
|
|||||||
return fmt.Errorf("horn disabled")
|
return fmt.Errorf("horn disabled")
|
||||||
}
|
}
|
||||||
return c.horn.HandlePayload(msg.Horn)
|
return c.horn.HandlePayload(msg.Horn)
|
||||||
|
case msg.AudioLevels != nil:
|
||||||
|
return c.handleAudioLevels(msg.AudioLevels)
|
||||||
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")
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ audioForward:
|
|||||||
# Optional stream suffix for fallback URL generation
|
# Optional stream suffix for fallback URL generation
|
||||||
streamSuffix: "-fwd"
|
streamSuffix: "-fwd"
|
||||||
|
|
||||||
|
audioLevels:
|
||||||
|
# Gains are multipliers (0.0 - 4.0) applied globally to all rovers.
|
||||||
|
hornGain: 1.0
|
||||||
|
ttsGain: 1.0
|
||||||
|
forwardGain: 1.0
|
||||||
|
|
||||||
homeAssistant:
|
homeAssistant:
|
||||||
url: "http://homeassistant.local:8123"
|
url: "http://homeassistant.local:8123"
|
||||||
token: "REPLACE_WITH_LONG_LIVED_TOKEN"
|
token: "REPLACE_WITH_LONG_LIVED_TOKEN"
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ require('./src/services/embedHttpService');
|
|||||||
require('./src/services/logStreamService');
|
require('./src/services/logStreamService');
|
||||||
require('./src/services/adminLogService');
|
require('./src/services/adminLogService');
|
||||||
require('./src/services/homeAssistantService');
|
require('./src/services/homeAssistantService');
|
||||||
|
require('./src/services/audioLevelsService');
|
||||||
require('./src/services/audioForwardService');
|
require('./src/services/audioForwardService');
|
||||||
require('./src/services/sessionService');
|
require('./src/services/sessionService');
|
||||||
require('./src/services/batteryManager');
|
require('./src/services/batteryManager');
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,7 +11,7 @@
|
|||||||
<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-5J3deGq0.js"></script>
|
<script type="module" crossorigin src="/assets/index-C4govqUc.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-WqYsCIRI.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-WqYsCIRI.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const logger = require('../globals/logger').child('audioForwardService');
|
|||||||
const { loadConfig } = require('../helpers/configLoader');
|
const { loadConfig } = require('../helpers/configLoader');
|
||||||
const roverManager = require('./roverManager');
|
const roverManager = require('./roverManager');
|
||||||
const { isAdmin } = require('./roleService');
|
const { isAdmin } = require('./roleService');
|
||||||
|
const { getAudioLevels, audioLevelsEvents } = require('./audioLevelsService');
|
||||||
|
|
||||||
const audioForwardEvents = new EventEmitter();
|
const audioForwardEvents = new EventEmitter();
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -200,6 +201,7 @@ function buildSilenceWriterArgs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildClipWriterArgs(filePath) {
|
function buildClipWriterArgs(filePath) {
|
||||||
|
const forwardGain = Math.max(0, Number(getAudioLevels()?.forwardGain) || 1);
|
||||||
return [
|
return [
|
||||||
'-hide_banner',
|
'-hide_banner',
|
||||||
'-loglevel',
|
'-loglevel',
|
||||||
@@ -209,7 +211,7 @@ function buildClipWriterArgs(filePath) {
|
|||||||
filePath,
|
filePath,
|
||||||
'-vn',
|
'-vn',
|
||||||
'-af',
|
'-af',
|
||||||
'aresample=16000,volume=12dB',
|
`aresample=16000,volume=${forwardGain}`,
|
||||||
'-f',
|
'-f',
|
||||||
's16le',
|
's16le',
|
||||||
'-ac',
|
'-ac',
|
||||||
@@ -394,6 +396,15 @@ roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
audioLevelsEvents.on('change', () => {
|
||||||
|
workers.forEach((worker, roverId) => {
|
||||||
|
if (worker?.contentKind === 'clip') {
|
||||||
|
// Restart clip writer so forward gain changes are immediately reflected.
|
||||||
|
startClipWriter(roverId, testAudioPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
socket.on('audio:testPlay', ({ roverId } = {}, cb = () => {}) => {
|
socket.on('audio:testPlay', ({ roverId } = {}, cb = () => {}) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
const io = require('../globals/io');
|
||||||
|
const logger = require('../globals/logger').child('audioLevelsService');
|
||||||
|
const { loadConfig } = require('../helpers/configLoader');
|
||||||
|
const { isAdmin } = require('./roleService');
|
||||||
|
const roverManager = require('./roverManager');
|
||||||
|
const { issueCommand } = require('./commandService');
|
||||||
|
|
||||||
|
const audioLevelsEvents = new EventEmitter();
|
||||||
|
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
|
||||||
|
const STORE_PATH = path.join(DATA_DIR, 'audio-levels.json');
|
||||||
|
const config = loadConfig();
|
||||||
|
const configuredDefaults = config.audioLevels || {};
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
hornGain: clampGain(configuredDefaults.hornGain, 1),
|
||||||
|
ttsGain: clampGain(configuredDefaults.ttsGain, 1),
|
||||||
|
forwardGain: clampGain(configuredDefaults.forwardGain, 1),
|
||||||
|
};
|
||||||
|
|
||||||
|
function clampGain(value, fallback = 1) {
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num)) return fallback;
|
||||||
|
return Math.max(0, Math.min(4, num));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStore(raw = {}) {
|
||||||
|
return {
|
||||||
|
hornGain: clampGain(raw.hornGain, DEFAULTS.hornGain),
|
||||||
|
ttsGain: clampGain(raw.ttsGain, DEFAULTS.ttsGain),
|
||||||
|
forwardGain: clampGain(raw.forwardGain, DEFAULTS.forwardGain),
|
||||||
|
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : null,
|
||||||
|
updatedBy: typeof raw.updatedBy === 'string' ? raw.updatedBy : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = null;
|
||||||
|
|
||||||
|
function loadState() {
|
||||||
|
if (state) return state;
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
|
||||||
|
state = normalizeStore(raw);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ENOENT') {
|
||||||
|
logger.warn('Failed to load audio levels store', err.message);
|
||||||
|
}
|
||||||
|
state = normalizeStore({});
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistState(next) {
|
||||||
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
const normalized = normalizeStore(next);
|
||||||
|
const tempPath = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`;
|
||||||
|
fs.writeFileSync(tempPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
|
||||||
|
fs.renameSync(tempPath, STORE_PATH);
|
||||||
|
state = normalized;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAudioLevels() {
|
||||||
|
const current = loadState();
|
||||||
|
return {
|
||||||
|
hornGain: current.hornGain,
|
||||||
|
ttsGain: current.ttsGain,
|
||||||
|
forwardGain: current.forwardGain,
|
||||||
|
updatedAt: current.updatedAt,
|
||||||
|
updatedBy: current.updatedBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitChange(reason = 'update') {
|
||||||
|
audioLevelsEvents.emit('change', {
|
||||||
|
reason,
|
||||||
|
levels: getAudioLevels(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushLevelsToRover(roverId) {
|
||||||
|
if (!roverId) return;
|
||||||
|
const record = roverManager.rovers.get(roverId);
|
||||||
|
if (!record || !record.ws) return;
|
||||||
|
try {
|
||||||
|
issueCommand(roverId, {
|
||||||
|
type: 'audioLevels',
|
||||||
|
audioLevels: getAudioLevels(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to push audio levels to rover', roverId, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushLevelsToAllRovers() {
|
||||||
|
roverManager.rovers.forEach((record, roverId) => {
|
||||||
|
if (record?.ws) {
|
||||||
|
pushLevelsToRover(roverId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAudioLevels(input = {}, actor = null) {
|
||||||
|
const current = loadState();
|
||||||
|
const next = {
|
||||||
|
...current,
|
||||||
|
hornGain: clampGain(input.hornGain, current.hornGain),
|
||||||
|
ttsGain: clampGain(input.ttsGain, current.ttsGain),
|
||||||
|
forwardGain: clampGain(input.forwardGain, current.forwardGain),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
updatedBy: actor,
|
||||||
|
};
|
||||||
|
persistState(next);
|
||||||
|
pushLevelsToAllRovers();
|
||||||
|
emitChange('set');
|
||||||
|
return getAudioLevels();
|
||||||
|
}
|
||||||
|
|
||||||
|
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
|
||||||
|
if (action === 'upsert' && roverId) {
|
||||||
|
pushLevelsToRover(roverId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('audioLevels:get', (_, cb = () => {}) => {
|
||||||
|
cb({ success: true, levels: getAudioLevels() });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('audioLevels:set', (payload = {}, cb = () => {}) => {
|
||||||
|
try {
|
||||||
|
if (!isAdmin(socket)) {
|
||||||
|
throw new Error('Not authorized');
|
||||||
|
}
|
||||||
|
const actor = socket?.data?.user?.username || null;
|
||||||
|
const levels = setAudioLevels(payload || {}, actor);
|
||||||
|
cb({ success: true, levels });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
loadState();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getAudioLevels,
|
||||||
|
setAudioLevels,
|
||||||
|
pushLevelsToRover,
|
||||||
|
audioLevelsEvents,
|
||||||
|
};
|
||||||
@@ -67,6 +67,9 @@ io.on('connection', (socket) => {
|
|||||||
if (!type) {
|
if (!type) {
|
||||||
throw new Error('type required');
|
throw new Error('type required');
|
||||||
}
|
}
|
||||||
|
if (type === 'audioLevels') {
|
||||||
|
throw new Error('audioLevels command is service-managed');
|
||||||
|
}
|
||||||
const payload = data ? { ...data } : {};
|
const payload = data ? { ...data } : {};
|
||||||
const isRebootCommand = type === 'reboot';
|
const isRebootCommand = type === 'reboot';
|
||||||
const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload));
|
const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload));
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const { getAdminReason } = require('./adminReasonService');
|
|||||||
const { subscribe } = require('./eventBus');
|
const { subscribe } = require('./eventBus');
|
||||||
const { getSocketIp, isLocalNetwork } = require('../helpers/ipResolver');
|
const { getSocketIp, isLocalNetwork } = require('../helpers/ipResolver');
|
||||||
const { getAudioForwardState, audioForwardEvents } = require('./audioForwardService');
|
const { getAudioForwardState, audioForwardEvents } = require('./audioForwardService');
|
||||||
|
const { getAudioLevels, audioLevelsEvents } = require('./audioLevelsService');
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const discordInvite = config.discord?.invite || null;
|
const discordInvite = config.discord?.invite || null;
|
||||||
@@ -93,6 +94,7 @@ function buildSession(socket) {
|
|||||||
verification: getVerificationStateForSocket(socket),
|
verification: getVerificationStateForSocket(socket),
|
||||||
isVerified: Boolean(socket?.data?.isVerified),
|
isVerified: Boolean(socket?.data?.isVerified),
|
||||||
audioForward: getAudioForwardState(),
|
audioForward: getAudioForwardState(),
|
||||||
|
audioLevels: getAudioLevels(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +266,10 @@ audioForwardEvents.on('change', () => {
|
|||||||
syncAll();
|
syncAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
audioLevelsEvents.on('change', () => {
|
||||||
|
syncAll();
|
||||||
|
});
|
||||||
|
|
||||||
// sync all sockets 20 seconds
|
// sync all sockets 20 seconds
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
logger.info('Periodic session sync for all clients');
|
logger.info('Periodic session sync for all clients');
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export default function AdminPanel() {
|
|||||||
rebootServer,
|
rebootServer,
|
||||||
playTestAudio,
|
playTestAudio,
|
||||||
stopTestAudio,
|
stopTestAudio,
|
||||||
|
setAudioLevels,
|
||||||
llmControl,
|
llmControl,
|
||||||
adminLogs,
|
adminLogs,
|
||||||
llmCommentaryState,
|
llmCommentaryState,
|
||||||
@@ -39,6 +40,12 @@ export default function AdminPanel() {
|
|||||||
const currentReason = session?.adminReason?.text || '';
|
const currentReason = session?.adminReason?.text || '';
|
||||||
const reasonUpdatedAt = session?.adminReason?.updatedAt || null;
|
const reasonUpdatedAt = session?.adminReason?.updatedAt || null;
|
||||||
const [reasonDraft, setReasonDraft] = useState(currentReason);
|
const [reasonDraft, setReasonDraft] = useState(currentReason);
|
||||||
|
const currentAudioLevels = session?.audioLevels || {};
|
||||||
|
const [audioLevelDraft, setAudioLevelDraft] = useState({
|
||||||
|
hornGain: Number.isFinite(currentAudioLevels.hornGain) ? currentAudioLevels.hornGain : 1,
|
||||||
|
ttsGain: Number.isFinite(currentAudioLevels.ttsGain) ? currentAudioLevels.ttsGain : 1,
|
||||||
|
forwardGain: Number.isFinite(currentAudioLevels.forwardGain) ? currentAudioLevels.forwardGain : 1,
|
||||||
|
});
|
||||||
|
|
||||||
const isAdmin =
|
const isAdmin =
|
||||||
session?.role === 'admin' ||
|
session?.role === 'admin' ||
|
||||||
@@ -170,6 +177,19 @@ export default function AdminPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAudioLevelDraft = (key) => (event) => {
|
||||||
|
const next = Number(event.target.value);
|
||||||
|
setAudioLevelDraft((current) => ({ ...(current || {}), [key]: Number.isFinite(next) ? next : 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAudioLevelsSave = async () => {
|
||||||
|
try {
|
||||||
|
await setAudioLevels(audioLevelDraft);
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGoalDraft(currentGoal);
|
setGoalDraft(currentGoal);
|
||||||
}, [currentGoal]);
|
}, [currentGoal]);
|
||||||
@@ -178,6 +198,14 @@ export default function AdminPanel() {
|
|||||||
setReasonDraft(currentReason);
|
setReasonDraft(currentReason);
|
||||||
}, [currentReason]);
|
}, [currentReason]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAudioLevelDraft({
|
||||||
|
hornGain: Number.isFinite(currentAudioLevels.hornGain) ? currentAudioLevels.hornGain : 1,
|
||||||
|
ttsGain: Number.isFinite(currentAudioLevels.ttsGain) ? currentAudioLevels.ttsGain : 1,
|
||||||
|
forwardGain: Number.isFinite(currentAudioLevels.forwardGain) ? currentAudioLevels.forwardGain : 1,
|
||||||
|
});
|
||||||
|
}, [currentAudioLevels.forwardGain, currentAudioLevels.hornGain, currentAudioLevels.ttsGain]);
|
||||||
|
|
||||||
const lockMap = useMemo(() => {
|
const lockMap = useMemo(() => {
|
||||||
const map = {};
|
const map = {};
|
||||||
roster.forEach((rover) => {
|
roster.forEach((rover) => {
|
||||||
@@ -210,6 +238,64 @@ export default function AdminPanel() {
|
|||||||
{serverRebooting ? 'Server rebooting...' : 'Reboot Server'}
|
{serverRebooting ? 'Server rebooting...' : 'Reboot Server'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>Global audio levels</span>
|
||||||
|
{session?.audioLevels?.updatedAt ? (
|
||||||
|
<span>Updated {new Date(session.audioLevels.updatedAt).toLocaleString()}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<label className="grid gap-0.5 text-xs text-slate-200">
|
||||||
|
<div className="flex items-center justify-between gap-0.5">
|
||||||
|
<span>Horn gain</span>
|
||||||
|
<span>{audioLevelDraft.hornGain.toFixed(2)}x</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="4"
|
||||||
|
step="0.01"
|
||||||
|
value={audioLevelDraft.hornGain}
|
||||||
|
onChange={handleAudioLevelDraft('hornGain')}
|
||||||
|
className="w-full accent-emerald-500"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-0.5 text-xs text-slate-200">
|
||||||
|
<div className="flex items-center justify-between gap-0.5">
|
||||||
|
<span>TTS gain</span>
|
||||||
|
<span>{audioLevelDraft.ttsGain.toFixed(2)}x</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="4"
|
||||||
|
step="0.01"
|
||||||
|
value={audioLevelDraft.ttsGain}
|
||||||
|
onChange={handleAudioLevelDraft('ttsGain')}
|
||||||
|
className="w-full accent-emerald-500"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-0.5 text-xs text-slate-200">
|
||||||
|
<div className="flex items-center justify-between gap-0.5">
|
||||||
|
<span>Forward gain</span>
|
||||||
|
<span>{audioLevelDraft.forwardGain.toFixed(2)}x</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="4"
|
||||||
|
step="0.01"
|
||||||
|
value={audioLevelDraft.forwardGain}
|
||||||
|
onChange={handleAudioLevelDraft('forwardGain')}
|
||||||
|
className="w-full accent-emerald-500"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-0.5 text-xs">
|
||||||
|
<button type="button" onClick={handleAudioLevelsSave} className="button-dark">
|
||||||
|
Apply audio levels
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
<span>Community goal</span>
|
<span>Community goal</span>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const SessionContext = createContext({
|
|||||||
rebootServer: async () => {},
|
rebootServer: async () => {},
|
||||||
playTestAudio: async () => {},
|
playTestAudio: async () => {},
|
||||||
stopTestAudio: async () => {},
|
stopTestAudio: async () => {},
|
||||||
|
setAudioLevels: async () => {},
|
||||||
llmControl: async () => {},
|
llmControl: async () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,6 +146,7 @@ export function SessionProvider({ children }) {
|
|||||||
rebootServer: () => emitWithAck('server:reboot'),
|
rebootServer: () => emitWithAck('server:reboot'),
|
||||||
playTestAudio: (roverId) => emitWithAck('audio:testPlay', { roverId }),
|
playTestAudio: (roverId) => emitWithAck('audio:testPlay', { roverId }),
|
||||||
stopTestAudio: (roverId) => emitWithAck('audio:testStop', { roverId }),
|
stopTestAudio: (roverId) => emitWithAck('audio:testStop', { roverId }),
|
||||||
|
setAudioLevels: (levels = {}) => emitWithAck('audioLevels:set', levels),
|
||||||
llmControl: (action, controls = {}) =>
|
llmControl: (action, controls = {}) =>
|
||||||
emitWithAck('llm:control', { controls: { action, ...controls } }),
|
emitWithAck('llm:control', { controls: { action, ...controls } }),
|
||||||
pushAlert: (alert) =>
|
pushAlert: (alert) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user