Compare commits

..

1 Commits

Author SHA1 Message Date
Alex f4a1ee5f65
Merge 5a0dce9984 into bcc307b147 2025-09-06 13:48:52 +00:00
5 changed files with 92 additions and 292 deletions

View File

@ -7,7 +7,6 @@ import (
"github.com/coder/websocket" "github.com/coder/websocket"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/jetkvm/kvm/internal/audio" "github.com/jetkvm/kvm/internal/audio"
"github.com/pion/webrtc/v4"
"github.com/rs/zerolog" "github.com/rs/zerolog"
) )
@ -17,81 +16,9 @@ func initAudioControlService() {
if audioControlService == nil { if audioControlService == nil {
sessionProvider := &SessionProviderImpl{} sessionProvider := &SessionProviderImpl{}
audioControlService = audio.NewAudioControlService(sessionProvider, logger) audioControlService = audio.NewAudioControlService(sessionProvider, logger)
// Set up callback for audio relay to get current session's audio track
audio.SetCurrentSessionCallback(func() audio.AudioTrackWriter {
return GetCurrentSessionAudioTrack()
})
} }
} }
// --- Global Convenience Functions for Audio Control ---
// StopAudioOutputAndRemoveTracks is a global helper to stop audio output subprocess and remove WebRTC tracks
func StopAudioOutputAndRemoveTracks() error {
initAudioControlService()
return audioControlService.MuteAudio(true)
}
// StartAudioOutputAndAddTracks is a global helper to start audio output subprocess and add WebRTC tracks
func StartAudioOutputAndAddTracks() error {
initAudioControlService()
return audioControlService.MuteAudio(false)
}
// StopMicrophoneAndRemoveTracks is a global helper to stop microphone subprocess and remove WebRTC tracks
func StopMicrophoneAndRemoveTracks() error {
initAudioControlService()
return audioControlService.MuteMicrophone(true)
}
// StartMicrophoneAndAddTracks is a global helper to start microphone subprocess and add WebRTC tracks
func StartMicrophoneAndAddTracks() error {
initAudioControlService()
return audioControlService.MuteMicrophone(false)
}
// IsAudioOutputActive is a global helper to check if audio output subprocess is running
func IsAudioOutputActive() bool {
initAudioControlService()
return audioControlService.IsAudioOutputActive()
}
// IsMicrophoneActive is a global helper to check if microphone subprocess is running
func IsMicrophoneActive() bool {
initAudioControlService()
return audioControlService.IsMicrophoneActive()
}
// ResetMicrophone is a global helper to reset the microphone
func ResetMicrophone() error {
initAudioControlService()
return audioControlService.ResetMicrophone()
}
// GetCurrentSessionAudioTrack returns the current session's audio track for audio relay
func GetCurrentSessionAudioTrack() *webrtc.TrackLocalStaticSample {
if currentSession != nil {
return currentSession.AudioTrack
}
return nil
}
// ConnectRelayToCurrentSession connects the audio relay to the current WebRTC session
func ConnectRelayToCurrentSession() error {
if currentTrack := GetCurrentSessionAudioTrack(); currentTrack != nil {
err := audio.UpdateAudioRelayTrack(currentTrack)
if err != nil {
logger.Error().Err(err).Msg("failed to connect current session's audio track to relay")
return err
}
logger.Info().Msg("connected current session's audio track to relay")
return nil
}
logger.Warn().Msg("no current session audio track found")
return nil
}
// handleAudioMute handles POST /audio/mute requests // handleAudioMute handles POST /audio/mute requests
func handleAudioMute(c *gin.Context) { func handleAudioMute(c *gin.Context) {
type muteReq struct { type muteReq struct {
@ -102,14 +29,9 @@ func handleAudioMute(c *gin.Context) {
c.JSON(400, gin.H{"error": "invalid request"}) c.JSON(400, gin.H{"error": "invalid request"})
return return
} }
initAudioControlService()
var err error err := audioControlService.MuteAudio(req.Muted)
if req.Muted {
err = StopAudioOutputAndRemoveTracks()
} else {
err = StartAudioOutputAndAddTracks()
}
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@ -123,18 +45,9 @@ func handleAudioMute(c *gin.Context) {
// handleMicrophoneStart handles POST /microphone/start requests // handleMicrophoneStart handles POST /microphone/start requests
func handleMicrophoneStart(c *gin.Context) { func handleMicrophoneStart(c *gin.Context) {
err := StartMicrophoneAndAddTracks() initAudioControlService()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true}) err := audioControlService.StartMicrophone()
}
// handleMicrophoneStop handles POST /microphone/stop requests
func handleMicrophoneStop(c *gin.Context) {
err := StopMicrophoneAndRemoveTracks()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@ -154,13 +67,9 @@ func handleMicrophoneMute(c *gin.Context) {
return return
} }
var err error initAudioControlService()
if req.Muted {
err = StopMicrophoneAndRemoveTracks()
} else {
err = StartMicrophoneAndAddTracks()
}
err := audioControlService.MuteMicrophone(req.Muted)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@ -171,7 +80,9 @@ func handleMicrophoneMute(c *gin.Context) {
// handleMicrophoneReset handles POST /microphone/reset requests // handleMicrophoneReset handles POST /microphone/reset requests
func handleMicrophoneReset(c *gin.Context) { func handleMicrophoneReset(c *gin.Context) {
err := ResetMicrophone() initAudioControlService()
err := audioControlService.ResetMicrophone()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return

View File

@ -22,49 +22,10 @@ func NewAudioControlService(sessionProvider SessionProvider, logger *zerolog.Log
} }
} }
// MuteAudio sets the audio mute state by controlling the audio output subprocess // MuteAudio sets the audio mute state
func (s *AudioControlService) MuteAudio(muted bool) error { func (s *AudioControlService) MuteAudio(muted bool) error {
if muted { SetAudioMuted(muted)
// Mute: Stop audio output subprocess and relay SetAudioRelayMuted(muted)
supervisor := GetAudioOutputSupervisor()
if supervisor != nil {
supervisor.Stop()
s.logger.Info().Msg("audio output supervisor stopped")
}
StopAudioRelay()
SetAudioMuted(true)
s.logger.Info().Msg("audio output muted (subprocess and relay stopped)")
} else {
// Unmute: Start audio output subprocess and relay
if !s.sessionProvider.IsSessionActive() {
return errors.New("no active session for audio unmute")
}
supervisor := GetAudioOutputSupervisor()
if supervisor != nil {
err := supervisor.Start()
if err != nil {
s.logger.Error().Err(err).Msg("failed to start audio output supervisor during unmute")
return err
}
s.logger.Info().Msg("audio output supervisor started")
}
// Start audio relay
err := StartAudioRelay(nil)
if err != nil {
s.logger.Error().Err(err).Msg("failed to start audio relay during unmute")
return err
}
// Connect the relay to the current WebRTC session's audio track
// This is needed because UpdateAudioRelayTrack is normally only called during session creation
if err := connectRelayToCurrentSession(); err != nil {
s.logger.Warn().Err(err).Msg("failed to connect relay to current session, audio may not work")
}
SetAudioMuted(false)
s.logger.Info().Msg("audio output unmuted (subprocess and relay started)")
}
// Broadcast audio mute state change via WebSocket // Broadcast audio mute state change via WebSocket
broadcaster := GetAudioEventBroadcaster() broadcaster := GetAudioEventBroadcaster()
@ -98,51 +59,16 @@ func (s *AudioControlService) StartMicrophone() error {
return nil return nil
} }
// StopMicrophone stops the microphone input // MuteMicrophone sets the microphone mute state
func (s *AudioControlService) StopMicrophone() error {
if !s.sessionProvider.IsSessionActive() {
return errors.New("no active session")
}
audioInputManager := s.sessionProvider.GetAudioInputManager()
if audioInputManager == nil {
return errors.New("audio input manager not available")
}
if !audioInputManager.IsRunning() {
s.logger.Info().Msg("microphone already stopped")
return nil
}
audioInputManager.Stop()
s.logger.Info().Msg("microphone stopped successfully")
return nil
}
// MuteMicrophone sets the microphone mute state by controlling the microphone process
func (s *AudioControlService) MuteMicrophone(muted bool) error { func (s *AudioControlService) MuteMicrophone(muted bool) error {
if muted { // Set microphone mute state using the audio relay
// Mute: Stop microphone process SetAudioRelayMuted(muted)
err := s.StopMicrophone()
if err != nil {
s.logger.Error().Err(err).Msg("failed to stop microphone during mute")
return err
}
s.logger.Info().Msg("microphone muted (process stopped)")
} else {
// Unmute: Start microphone process
err := s.StartMicrophone()
if err != nil {
s.logger.Error().Err(err).Msg("failed to start microphone during unmute")
return err
}
s.logger.Info().Msg("microphone unmuted (process started)")
}
// Broadcast microphone mute state change via WebSocket // Broadcast microphone mute state change via WebSocket
broadcaster := GetAudioEventBroadcaster() broadcaster := GetAudioEventBroadcaster()
broadcaster.BroadcastAudioDeviceChanged(!muted, "microphone_mute_changed") broadcaster.BroadcastAudioDeviceChanged(!muted, "microphone_mute_changed")
s.logger.Info().Bool("muted", muted).Msg("microphone mute state updated")
return nil return nil
} }
@ -248,22 +174,3 @@ func (s *AudioControlService) UnsubscribeFromAudioEvents(connectionID string, lo
broadcaster := GetAudioEventBroadcaster() broadcaster := GetAudioEventBroadcaster()
broadcaster.Unsubscribe(connectionID) broadcaster.Unsubscribe(connectionID)
} }
// IsAudioOutputActive returns whether the audio output subprocess is running
func (s *AudioControlService) IsAudioOutputActive() bool {
return !IsAudioMuted() && IsAudioRelayRunning()
}
// IsMicrophoneActive returns whether the microphone subprocess is running
func (s *AudioControlService) IsMicrophoneActive() bool {
if !s.sessionProvider.IsSessionActive() {
return false
}
audioInputManager := s.sessionProvider.GetAudioInputManager()
if audioInputManager == nil {
return false
}
return audioInputManager.IsRunning()
}

View File

@ -1,7 +1,6 @@
package audio package audio
import ( import (
"errors"
"sync" "sync"
) )
@ -108,37 +107,3 @@ func UpdateAudioRelayTrack(audioTrack AudioTrackWriter) error {
globalRelay.UpdateTrack(audioTrack) globalRelay.UpdateTrack(audioTrack)
return nil return nil
} }
// CurrentSessionCallback is a function type for getting the current session's audio track
type CurrentSessionCallback func() AudioTrackWriter
// currentSessionCallback holds the callback function to get the current session's audio track
var currentSessionCallback CurrentSessionCallback
// SetCurrentSessionCallback sets the callback function to get the current session's audio track
func SetCurrentSessionCallback(callback CurrentSessionCallback) {
currentSessionCallback = callback
}
// connectRelayToCurrentSession connects the audio relay to the current WebRTC session's audio track
// This is used when restarting the relay during unmute operations
func connectRelayToCurrentSession() error {
if currentSessionCallback == nil {
return errors.New("no current session callback set")
}
track := currentSessionCallback()
if track == nil {
return errors.New("no current session audio track available")
}
relayMutex.Lock()
defer relayMutex.Unlock()
if globalRelay != nil {
globalRelay.UpdateTrack(track)
return nil
}
return errors.New("no global relay running")
}

View File

@ -69,8 +69,10 @@ export default function AudioControlPopover({ microphone }: AudioControlPopoverP
// Microphone state from props // Microphone state from props
const { const {
isMicrophoneActive, isMicrophoneActive,
isMicrophoneMuted,
startMicrophone, startMicrophone,
stopMicrophone, stopMicrophone,
toggleMicrophoneMute,
syncMicrophoneState, syncMicrophoneState,
// Loading states // Loading states
isStarting, isStarting,
@ -136,35 +138,15 @@ export default function AudioControlPopover({ microphone }: AudioControlPopoverP
}; };
const handleToggleMute = async () => { const handleToggleMute = async () => {
const now = Date.now();
// Prevent rapid clicking
if (isLoading || (now - lastClickTime < CLICK_COOLDOWN)) {
return;
}
setLastClickTime(now);
setIsLoading(true); setIsLoading(true);
try { try {
if (isMuted) { const resp = await api.POST("/audio/mute", { muted: !isMuted });
// Unmute: Start audio output process and notify backend if (!resp.ok) {
const resp = await api.POST("/audio/mute", { muted: false }); // Failed to toggle mute
if (!resp.ok) {
throw new Error(`Failed to unmute audio: ${resp.status}`);
}
// WebSocket will handle the state update automatically
} else {
// Mute: Stop audio output process and notify backend
const resp = await api.POST("/audio/mute", { muted: true });
if (!resp.ok) {
throw new Error(`Failed to mute audio: ${resp.status}`);
}
// WebSocket will handle the state update automatically
} }
} catch (error) { // WebSocket will handle the state update automatically
const errorMessage = error instanceof Error ? error.message : "Failed to toggle audio mute"; } catch {
notifications.error(errorMessage); // Failed to toggle mute
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -197,6 +179,27 @@ export default function AudioControlPopover({ microphone }: AudioControlPopoverP
} }
}; };
const handleToggleMicrophone = async () => {
const now = Date.now();
// Prevent rapid clicking - if any operation is in progress or within cooldown, ignore the click
if (isStarting || isStopping || isToggling || (now - lastClickTime < CLICK_COOLDOWN)) {
return;
}
setLastClickTime(now);
try {
const result = isMicrophoneActive ? await stopMicrophone() : await startMicrophone(selectedInputDevice);
if (!result.success && result.error) {
notifications.error(result.error.message);
}
} catch {
// Failed to toggle microphone
notifications.error("An unexpected error occurred");
}
};
const handleToggleMicrophoneMute = async () => { const handleToggleMicrophoneMute = async () => {
const now = Date.now(); const now = Date.now();
@ -208,22 +211,13 @@ export default function AudioControlPopover({ microphone }: AudioControlPopoverP
setLastClickTime(now); setLastClickTime(now);
try { try {
if (isMicrophoneActive) { const result = await toggleMicrophoneMute();
// Microphone is active: stop the microphone process and WebRTC tracks if (!result.success && result.error) {
const result = await stopMicrophone(); notifications.error(result.error.message);
if (!result.success && result.error) {
notifications.error(result.error.message);
}
} else {
// Microphone is inactive: start the microphone process and WebRTC tracks
const result = await startMicrophone(selectedInputDevice);
if (!result.success && result.error) {
notifications.error(result.error.message);
}
} }
} catch (error) { } catch {
const errorMessage = error instanceof Error ? error.message : "Failed to toggle microphone"; // Failed to toggle microphone mute
notifications.error(errorMessage); notifications.error("Failed to toggle microphone mute");
} }
}; };
@ -231,7 +225,7 @@ export default function AudioControlPopover({ microphone }: AudioControlPopoverP
const handleMicrophoneDeviceChange = async (deviceId: string) => { const handleMicrophoneDeviceChange = async (deviceId: string) => {
setSelectedInputDevice(deviceId); setSelectedInputDevice(deviceId);
// If microphone is currently active (unmuted), restart it with the new device // If microphone is currently active, restart it with the new device
if (isMicrophoneActive) { if (isMicrophoneActive) {
try { try {
// Stop current microphone // Stop current microphone
@ -299,8 +293,8 @@ export default function AudioControlPopover({ microphone }: AudioControlPopoverP
</div> </div>
<Button <Button
size="SM" size="SM"
theme={isMuted ? "primary" : "danger"} theme={isMuted ? "danger" : "primary"}
text={isMuted ? "Enable" : "Disable"} text={isMuted ? "Unmute" : "Mute"}
onClick={handleToggleMute} onClick={handleToggleMute}
disabled={isLoading} disabled={isLoading}
/> />
@ -318,26 +312,50 @@ export default function AudioControlPopover({ microphone }: AudioControlPopoverP
<div className="flex items-center justify-between rounded-lg bg-slate-50 p-3 dark:bg-slate-700"> <div className="flex items-center justify-between rounded-lg bg-slate-50 p-3 dark:bg-slate-700">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{isMicrophoneActive ? ( {isMicrophoneActive ? (
<MdMic className="h-5 w-5 text-green-500" /> isMicrophoneMuted ? (
<MdMicOff className="h-5 w-5 text-yellow-500" />
) : (
<MdMic className="h-5 w-5 text-green-500" />
)
) : ( ) : (
<MdMicOff className="h-5 w-5 text-red-500" /> <MdMicOff className="h-5 w-5 text-red-500" />
)} )}
<span className="font-medium text-slate-900 dark:text-slate-100"> <span className="font-medium text-slate-900 dark:text-slate-100">
{isMicrophoneActive ? "Unmuted" : "Muted"} {!isMicrophoneActive
? "Inactive"
: isMicrophoneMuted
? "Muted"
: "Active"
}
</span> </span>
</div> </div>
<Button <div className="flex gap-2">
size="SM" <Button
theme={isMicrophoneActive ? "danger" : "primary"} size="SM"
text={ theme={isMicrophoneActive ? "danger" : "primary"}
isStarting ? "Enabling..." : text={
isStopping ? "Disabling..." : isStarting ? "Starting..." :
isMicrophoneActive ? "Disable" : "Enable" isStopping ? "Stopping..." :
} isMicrophoneActive ? "Stop" : "Start"
onClick={handleToggleMicrophoneMute} }
disabled={isStarting || isStopping || isToggling} onClick={handleToggleMicrophone}
loading={isStarting || isStopping} disabled={isStarting || isStopping || isToggling}
/> loading={isStarting || isStopping}
/>
{isMicrophoneActive && (
<Button
size="SM"
theme={isMicrophoneMuted ? "danger" : "light"}
text={
isToggling ? (isMicrophoneMuted ? "Unmuting..." : "Muting...") :
isMicrophoneMuted ? "Unmute" : "Mute"
}
onClick={handleToggleMicrophoneMute}
disabled={isStarting || isStopping || isToggling}
loading={isToggling}
/>
)}
</div>
</div> </div>

1
web.go
View File

@ -163,7 +163,6 @@ func setupRouter() *gin.Engine {
protected.GET("/microphone/quality", handleMicrophoneQuality) protected.GET("/microphone/quality", handleMicrophoneQuality)
protected.POST("/microphone/quality", handleSetMicrophoneQuality) protected.POST("/microphone/quality", handleSetMicrophoneQuality)
protected.POST("/microphone/start", handleMicrophoneStart) protected.POST("/microphone/start", handleMicrophoneStart)
protected.POST("/microphone/stop", handleMicrophoneStop)
protected.POST("/microphone/mute", handleMicrophoneMute) protected.POST("/microphone/mute", handleMicrophoneMute)
protected.POST("/microphone/reset", handleMicrophoneReset) protected.POST("/microphone/reset", handleMicrophoneReset)
} }