You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

WebRTC直播观看端setRemoteDescription状态错误求助

WebRTC直播观看端setRemoteDescription状态错误排查与修复

问题根源

错误DOMException: Failed to execute 'setRemoteDescription' on 'RTCPeerConnection': Failed to set remote answer sdp: Called in wrong state: stable的核心原因:

  1. SDP类型不匹配:服务器的handleWatch函数错误地将推流端与服务器连接生成的Answer类型SDP,标记为Offer发送给观看端。
  2. 状态机规则违反:观看端的PeerConnection处于stable状态(无待处理信令),此时传入Answer类型的SDP,不符合WebRTC"先收Offer再发Answer"的信令流程。
  3. 架构设计缺陷:当前服务器复用推流端的PeerConnection对接所有观看端,不符合WebRTC一对一连接的模型,每个观看端需要与服务器建立独立的PeerConnection来接收媒体流。

修复方案

1. 服务器端(Go代码)修改

需要重构媒体流转发逻辑,存储推流的Track而非原PeerConnection,为每个观看端创建独立的连接:

调整结构体与全局存储

// 修改Stream结构体,存储推流的媒体Track
type Stream struct {
    License string
    Tracks  []*webrtc.TrackLocalStaticRTP
}

// 新增观看端PeerConnection存储,用于关联后续Answer处理
var (
    streams           = make(map[string]*Stream)
    watcherConnections = make(map[string]*webrtc.PeerConnection)
    streamsMutex      sync.Mutex
)

更新handlePublish函数,保存推流Track

func handlePublish(c *gin.Context) {
    var offerRequest OfferRequest
    if err := c.BindJSON(&offerRequest); err != nil {
        log.Println("Error decoding offer request:", err)
        c.JSON(http.StatusBadRequest, gin.H{"error": "Bad Request"})
        return
    }
    offer := offerRequest.SessionDescription
    license := offerRequest.License

    peerConnection, err := webrtc.NewPeerConnection(webrtc.Configuration{})
    if err != nil {
        log.Println("Error creating peer connection:", err)
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal Server Error"})
        return
    }

    var tracks []*webrtc.TrackLocalStaticRTP
    // 接收推流端的Track并创建本地静态Track用于转发
    peerConnection.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
        localTrack, err := webrtc.NewTrackLocalStaticRTP(track.Codec().RTPCodecCapability, track.ID(), track.StreamID())
        if err != nil {
            panic(err)
        }
        tracks = append(tracks, localTrack)

        // 异步读取推流RTP包并转发到本地Track
        go func() {
            defer localTrack.Close()
            for {
                rtp, _, err := track.ReadRTP()
                if err != nil {
                    return
                }
                if err := localTrack.WriteRTP(rtp); err != nil {
                    return
                }
            }
        }()
    })

    peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
        if candidate != nil {
            fmt.Println("ICE Candidate:", candidate)
        }
    })

    if err = peerConnection.SetRemoteDescription(offer); err != nil {
        log.Println("Error setting remote description:", err)
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal Server Error"})
        return
    }

    answer, err := peerConnection.CreateAnswer(nil)
    if err != nil {
        log.Println("Error creating answer:", err)
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal Server Error"})
        return
    }

    if err = peerConnection.SetLocalDescription(answer); err != nil {
        log.Println("Error setting local description:", err)
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal Server Error"})
        return
    }

    streamsMutex.Lock()
    streams[license] = &Stream{
        License: license,
        Tracks:  tracks,
    }
    streamsMutex.Unlock()

    c.JSON(http.StatusOK, gin.H{"answer": answer})
}

修改handleWatch函数,为观看端生成合法Offer

func handleWatch(c *gin.Context) {
    license := c.Param("license")

    streamsMutex.Lock()
    stream, found := streams[license]
    streamsMutex.Unlock()

    if !found {
        log.Println("Stream not found for license:", license)
        c.JSON(http.StatusNotFound, gin.H{"error": "Stream not found"})
        return
    }

    // 为当前观看端创建独立的PeerConnection
    watcherPC, err := webrtc.NewPeerConnection(webrtc.Configuration{})
    if err != nil {
        log.Println("Error creating watcher peer connection:", err)
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal Server Error"})
        return
    }

    // 将推流Track添加到观看端的PeerConnection
    for _, track := range stream.Tracks {
        _, err := watcherPC.AddTrack(track)
        if err != nil {
            log.Println("Error adding track to watcher PC:", err)
            watcherPC.Close()
            c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal Server Error"})
            return
        }
    }

    // 创建合法的Offer并设置本地描述
    offer, err := watcherPC.CreateOffer(nil)
    if err != nil {
        log.Println("Error creating offer for watcher:", err)
        watcherPC.Close()
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal Server Error"})
        return
    }

    if err := watcherPC.SetLocalDescription(offer); err != nil {
        log.Println("Error setting local description for watcher:", err)
        watcherPC.Close()
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal Server Error"})
        return
    }

    // 生成唯一标识关联观看端PeerConnection
    watcherId := fmt.Sprintf("%s-%d", license, time.Now().UnixNano())
    streamsMutex.Lock()
    watcherConnections[watcherId] = watcherPC
    streamsMutex.Unlock()

    // 返回正确的Offer和watcherId
    c.JSON(http.StatusOK, gin.H{
        "offer":     offer,
        "watcherId": watcherId,
    })
}

更新handleAnswer函数,匹配观看端PeerConnection

func handleAnswer(c *gin.Context) {
    var answer webrtc.SessionDescription
    if err := c.BindJSON(&answer); err != nil {
        log.Println("Error decoding answer request:", err)
        c.JSON(http.StatusBadRequest, gin.H{"error": "Bad Request"})
        return
    }

    watcherId := c.Query("watcherId")
    if watcherId == "" {
        c.JSON(http.StatusBadRequest, gin.H{"error": "watcherId is required"})
        return
    }

    streamsMutex.Lock()
    watcherPC, found := watcherConnections[watcherId]
    streamsMutex.Unlock()

    if !found {
        log.Println("Watcher not found for id:", watcherId)
        c.JSON(http.StatusNotFound, gin.H{"error": "Watcher not found"})
        return
    }

    if err := watcherPC.SetRemoteDescription(answer); err != nil {
        log.Println("Error setting remote description for watcher:", err)
        watcherPC.Close()
        delete(watcherConnections, watcherId)
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal Server Error"})
        return
    }

    c.JSON(http.StatusOK, gin.H{"message": "Answer processed successfully"})
}

2. 观看端(JavaScript)修改

确保每次加入创建新的PeerConnection,并携带watcherId发送Answer:

let peerConnection;
const signalingServerUrl = "http://localhost:8080";

document.getElementById('joinButton').addEventListener('click', joinStream);

async function joinStream() {
    const license = document.getElementById('licenseInput').value;
    if (!license) {
        alert("Please enter a license");
        return;
    }

    try {
        const response = await fetch(`${signalingServerUrl}/watch/${license}`);
        if (!response.ok) {
            throw new Error('Failed to receive offer');
        }
        const data = await response.json();
        // 保存watcherId用于后续发送Answer
        window.watcherId = data.watcherId;
        await handleOffer(data.offer);
    } catch (error) {
        console.error('Error joining stream:', error);
    }
}

async function handleOffer(offer) {
    try {
        // 每次加入创建新的PeerConnection,避免状态冲突
        peerConnection = new RTCPeerConnection();
        peerConnection.addEventListener('icecandidate', handleICECandidate);
        peerConnection.addEventListener('track', handleTrack);

        // 设置合法的Offer类型SDP
        await peerConnection.setRemoteDescription(new RTCSessionDescription(offer));

        // 创建并设置Answer
        const answer = await peerConnection.createAnswer();
        await peerConnection.setLocalDescription(answer);

        // 发送Answer时携带watcherId
        const answerData = {
            license: document.getElementById('licenseInput').value,
            sdp: peerConnection.localDescription
        };

        const response = await fetch(`${signalingServerUrl}/answer/${document.getElementById('licenseInput').value}?watcherId=${window.watcherId}`, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(answerData)
        });

        if (!response.ok) {
            throw new Error('Failed to send answer');
        }

        console.log('Answer sent successfully');
    } catch (error) {
        console.error('Error handling offer:', error);
    }
}

function handleICECandidate(event) {
    console.log('ICE candidate:', event.candidate);
}

function handleTrack(event) {
    const remoteVideo = document.getElementById('remoteVideo');
    if (remoteVideo.srcObject !== event.streams[0]) {
        remoteVideo.srcObject = event.streams[0];
    }
}

关键修复点总结

  • 服务器为每个观看端创建独立的PeerConnection,不再复用推流端连接
  • 服务器存储推流媒体Track,通过转发实现多观看端直播
  • 发送给观看端的是合法的Offer类型SDP,匹配WebRTC信令流程
  • 观看端每次加入创建新PeerConnection,避免状态冲突

内容的提问来源于stack exchange,提问作者Good

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.21 05:29:50