diff --git a/internal/config/load.go b/internal/config/load.go index 681e611..2dd18a1 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -41,14 +41,15 @@ func Load(configPath string) (Config, error) { // applyEnv overrides config fields with environment variables. func applyEnv(cfg *Config) { - if v := os.Getenv("DOUBAO_APP_ID"); v != "" { - cfg.Doubao.AppID = v + envStringMap := map[string]*string{ + "DOUBAO_APP_ID": &cfg.Doubao.AppID, + "DOUBAO_ACCESS_TOKEN": &cfg.Doubao.AccessToken, + "DOUBAO_RESOURCE_ID": &cfg.Doubao.ResourceID, } - if v := os.Getenv("DOUBAO_ACCESS_TOKEN"); v != "" { - cfg.Doubao.AccessToken = v - } - if v := os.Getenv("DOUBAO_RESOURCE_ID"); v != "" { - cfg.Doubao.ResourceID = v + for key, target := range envStringMap { + if v := os.Getenv(key); v != "" { + *target = v + } } if v := os.Getenv("PORT"); v != "" { if port, err := strconv.Atoi(v); err == nil { diff --git a/internal/tls/generate.go b/internal/tls/generate.go deleted file mode 100644 index 10e6589..0000000 --- a/internal/tls/generate.go +++ /dev/null @@ -1,72 +0,0 @@ -package tls - -import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" - "fmt" - "math/big" - "net" - "os" - "time" -) - -// generateSelfSigned creates a self-signed certificate for the given IP, -// saves it to disk, and returns the tls.Certificate. -func generateSelfSigned(lanIP, certFile, keyFile string) (tls.Certificate, error) { - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return tls.Certificate{}, fmt.Errorf("generate key: %w", err) - } - - serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) - if err != nil { - return tls.Certificate{}, fmt.Errorf("generate serial: %w", err) - } - - template := x509.Certificate{ - SerialNumber: serialNumber, - Subject: pkix.Name{ - Organization: []string{"VoicePaste"}, - CommonName: "VoicePaste Local", - }, - NotBefore: time.Now(), - NotAfter: time.Now().Add(365 * 24 * time.Hour), // 1 year - KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - BasicConstraintsValid: true, - IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP(lanIP)}, - DNSNames: []string{"localhost"}, - } - - certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) - if err != nil { - return tls.Certificate{}, fmt.Errorf("create certificate: %w", err) - } - - // Save cert PEM - certOut, err := os.Create(certFile) - if err != nil { - return tls.Certificate{}, fmt.Errorf("create cert file: %w", err) - } - defer certOut.Close() - pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}) - - // Save key PEM - keyDER, err := x509.MarshalECPrivateKey(key) - if err != nil { - return tls.Certificate{}, fmt.Errorf("marshal key: %w", err) - } - keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) - if err != nil { - return tls.Certificate{}, fmt.Errorf("create key file: %w", err) - } - defer keyOut.Close() - pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) - - return tls.LoadX509KeyPair(certFile, keyFile) -} diff --git a/internal/tls/tls.go b/internal/tls/tls.go index 12c53d3..a4f2b1e 100644 --- a/internal/tls/tls.go +++ b/internal/tls/tls.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "log/slog" - "net" "net/http" "os" "path/filepath" @@ -26,11 +25,10 @@ func certDir() string { return dir } -// Result holds the TLS config and metadata about which cert source was used. +// Result holds the TLS config and the AnyIP hostname. type Result struct { Config *tls.Config - AnyIP bool // true if AnyIP cert is active - Host string // hostname to use in URLs (AnyIP domain or raw IP) + Host string // AnyIP hostname (e.g. voicepaste-192-168-1-5.anyip.dev) } // AnyIPHost returns the AnyIP hostname for a given LAN IP. @@ -40,82 +38,61 @@ func AnyIPHost(lanIP string) string { return fmt.Sprintf("voicepaste-%s.anyip.dev", dashed) } -// GetTLSConfig returns a TLS config for the given LAN IP. -// Priority: cached AnyIP → download AnyIP → cached self-signed → generate self-signed. +// GetTLSConfig returns a TLS config using AnyIP wildcard certificate. +// It tries cached cert first, then downloads fresh if needed. func GetTLSConfig(lanIP string) (*Result, error) { dir := certDir() anyipDir := filepath.Join(dir, "anyip") os.MkdirAll(anyipDir, 0700) - anyipCert := filepath.Join(anyipDir, "fullchain.pem") - anyipKey := filepath.Join(anyipDir, "privkey.pem") + certFile := filepath.Join(anyipDir, "fullchain.pem") + keyFile := filepath.Join(anyipDir, "privkey.pem") + host := AnyIPHost(lanIP) - // 1. Try cached AnyIP cert - if cert, err := tls.LoadX509KeyPair(anyipCert, anyipKey); err == nil { - if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil { - if time.Now().Before(leaf.NotAfter.Add(-24 * time.Hour)) { // 1 day buffer - slog.Info("using cached AnyIP certificate", "expires", leaf.NotAfter.Format("2006-01-02")) - return &Result{ - Config: &tls.Config{Certificates: []tls.Certificate{cert}}, - AnyIP: true, - Host: AnyIPHost(lanIP), - }, nil - } - } + // Try cached cert first + if cert, err := loadAndValidateCert(certFile, keyFile); err == nil { + slog.Info("using cached AnyIP certificate") + return &Result{ + Config: &tls.Config{Certificates: []tls.Certificate{cert}}, + Host: host, + }, nil } - // 2. Try downloading AnyIP cert - if err := downloadAnyIPCert(anyipCert, anyipKey); err == nil { - if cert, err := tls.LoadX509KeyPair(anyipCert, anyipKey); err == nil { - slog.Info("downloaded fresh AnyIP certificate") - return &Result{ - Config: &tls.Config{Certificates: []tls.Certificate{cert}}, - AnyIP: true, - Host: AnyIPHost(lanIP), - }, nil - } - } else { - slog.Warn("AnyIP cert download failed, falling back to self-signed", "err", err) + // Download fresh cert + slog.Info("downloading AnyIP certificate") + if err := downloadAnyIPCert(certFile, keyFile); err != nil { + return nil, fmt.Errorf("failed to download AnyIP certificate: %w", err) } - // 3. Try cached self-signed - ssCert := filepath.Join(dir, "cert.pem") - ssKey := filepath.Join(dir, "key.pem") - if cert, err := tls.LoadX509KeyPair(ssCert, ssKey); err == nil { - if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil { - if time.Now().Before(leaf.NotAfter) && certCoversIP(leaf, lanIP) { - slog.Info("using cached self-signed certificate", "expires", leaf.NotAfter.Format("2006-01-02")) - return &Result{ - Config: &tls.Config{Certificates: []tls.Certificate{cert}}, - Host: lanIP, - }, nil - } - } - } - - // 4. Generate self-signed - slog.Info("generating self-signed TLS certificate", "ip", lanIP) - cert, err := generateSelfSigned(lanIP, ssCert, ssKey) + cert, err := tls.LoadX509KeyPair(certFile, keyFile) if err != nil { - return nil, fmt.Errorf("generate TLS cert: %w", err) + return nil, fmt.Errorf("failed to load downloaded certificate: %w", err) } + + slog.Info("downloaded fresh AnyIP certificate") return &Result{ Config: &tls.Config{Certificates: []tls.Certificate{cert}}, - Host: lanIP, + Host: host, }, nil } -// certCoversIP checks if the certificate covers the given IP. -func certCoversIP(cert *x509.Certificate, ip string) bool { - target := net.ParseIP(ip) - if target == nil { - return false +// loadAndValidateCert loads a certificate and validates it's not expired. +func loadAndValidateCert(certFile, keyFile string) (tls.Certificate, error) { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return tls.Certificate{}, err } - for _, certIP := range cert.IPAddresses { - if certIP.Equal(target) { - return true - } + + leaf, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + return tls.Certificate{}, err } - return false + + // Check if cert expires within 24 hours + if time.Now().After(leaf.NotAfter.Add(-24 * time.Hour)) { + return tls.Certificate{}, fmt.Errorf("certificate expired or expiring soon") + } + + return cert, nil } // downloadAnyIPCert downloads the AnyIP wildcard cert and key. diff --git a/internal/ws/handler.go b/internal/ws/handler.go index 77df8e4..c1bd4d1 100644 --- a/internal/ws/handler.go +++ b/internal/ws/handler.go @@ -9,6 +9,17 @@ import ( "github.com/gofiber/fiber/v3" ) +// session holds the state for a single WebSocket connection. +type session struct { + conn *websocket.Conn + log *slog.Logger + resultCh chan ServerMsg + previewMu sync.Mutex + previewText string + sendAudio func([]byte) + cleanup func() + active bool +} // PasteFunc is called when the server should paste text into the focused app. type PasteFunc func(text string) error @@ -53,134 +64,125 @@ func (h *Handler) Register(app *fiber.App) { } func (h *Handler) handleConn(c *websocket.Conn) { - log := slog.With("remote", c.RemoteAddr().String()) - log.Info("ws connected") - defer log.Info("ws disconnected") - - // Result channel for ASR → phone - resultCh := make(chan ServerMsg, 32) - defer close(resultCh) - - // Writer goroutine: single writer to avoid concurrent writes - // bigmodel_async with enable_nonstream: server returns full text each time (not incremental) - // We replace preview text on each update instead of accumulating. - var wg sync.WaitGroup - var previewMu sync.Mutex - var previewText string - wg.Add(1) - go func() { - defer wg.Done() - for msg := range resultCh { - // Replace preview text with latest result (full mode) - if msg.Type == MsgPartial || msg.Type == MsgFinal { - previewMu.Lock() - previewText = msg.Text - preview := ServerMsg{Type: msg.Type, Text: previewText} - previewMu.Unlock() - if err := c.WriteMessage(websocket.TextMessage, preview.Bytes()); err != nil { - log.Warn("ws write error", "err", err) - return - } - continue - } - // Forward other messages (error, pasted) as-is - if err := c.WriteMessage(websocket.TextMessage, msg.Bytes()); err != nil { - log.Warn("ws write error", "err", err) - return - } - } - }() - - // ASR session state - var ( - sendAudio func([]byte) - cleanup func() - active bool - ) - defer func() { - if cleanup != nil { - cleanup() - } - wg.Wait() - }() - - for { - mt, data, err := c.ReadMessage() - if err != nil { - break - } - - switch mt { - case websocket.BinaryMessage: - // Audio frame - if active && sendAudio != nil { - sendAudio(data) - } - - case websocket.TextMessage: - var msg ClientMsg - if err := json.Unmarshal(data, &msg); err != nil { - log.Warn("invalid json", "err", err) - continue - } - switch msg.Type { - case MsgStart: - if active { - continue - } - // Reset preview text for new session - previewMu.Lock() - previewText = "" - previewMu.Unlock() - sa, cl, err := h.asrFactory(resultCh) - if err != nil { - log.Error("asr start failed", "err", err) - resultCh <- ServerMsg{Type: MsgError, Message: "ASR start failed"} - continue - } - sendAudio = sa - cleanup = cl - active = true - log.Info("recording started") - - case MsgStop: - if !active { - continue - } - // Finish ASR session — waits for final result from readLoop - if cleanup != nil { - cleanup() - cleanup = nil - } - sendAudio = nil - active = false - // Paste the final preview text - previewMu.Lock() - finalText := previewText - previewText = "" - previewMu.Unlock() - if finalText != "" && h.pasteFunc != nil { - if err := h.pasteFunc(finalText); err != nil { - log.Error("auto-paste failed", "err", err) - } else { - resultCh <- ServerMsg{Type: MsgPasted} - } - } - log.Info("recording stopped") - - case MsgPaste: - if msg.Text == "" { - continue - } - if h.pasteFunc != nil { - if err := h.pasteFunc(msg.Text); err != nil { - log.Error("paste failed", "err", err) - resultCh <- ServerMsg{Type: MsgError, Message: "paste failed"} - } else { - resultCh <- ServerMsg{Type: MsgPasted} - } - } - } - } - } + sess := &session{ + conn: c, + log: slog.With("remote", c.RemoteAddr().String()), + resultCh: make(chan ServerMsg, 32), + } + sess.log.Info("ws connected") + defer sess.log.Info("ws disconnected") + defer close(sess.resultCh) + defer sess.cleanupASR() + var wg sync.WaitGroup + wg.Add(1) + go sess.writerLoop(&wg) + defer wg.Wait() + for { + mt, data, err := c.ReadMessage() + if err != nil { + break + } + if mt == websocket.BinaryMessage { + sess.handleAudioFrame(data) + } else if mt == websocket.TextMessage { + h.handleTextMessage(sess, data) + } + } +} +func (s *session) writerLoop(wg *sync.WaitGroup) { + defer wg.Done() + for msg := range s.resultCh { + if msg.Type == MsgPartial || msg.Type == MsgFinal { + s.previewMu.Lock() + s.previewText = msg.Text + preview := ServerMsg{Type: msg.Type, Text: s.previewText} + s.previewMu.Unlock() + if err := s.conn.WriteMessage(websocket.TextMessage, preview.Bytes()); err != nil { + s.log.Warn("ws write error", "err", err) + return + } + continue + } + if err := s.conn.WriteMessage(websocket.TextMessage, msg.Bytes()); err != nil { + s.log.Warn("ws write error", "err", err) + return + } + } +} +func (s *session) handleAudioFrame(data []byte) { + if s.active && s.sendAudio != nil { + s.sendAudio(data) + } +} +func (h *Handler) handleTextMessage(s *session, data []byte) { + var msg ClientMsg + if err := json.Unmarshal(data, &msg); err != nil { + s.log.Warn("invalid json", "err", err) + return + } + switch msg.Type { + case MsgStart: + h.handleStart(s) + case MsgStop: + h.handleStop(s) + case MsgPaste: + h.handlePaste(s, msg.Text) + } +} +func (h *Handler) handleStart(s *session) { + if s.active { + return + } + s.previewMu.Lock() + s.previewText = "" + s.previewMu.Unlock() + sa, cl, err := h.asrFactory(s.resultCh) + if err != nil { + s.log.Error("asr start failed", "err", err) + s.resultCh <- ServerMsg{Type: MsgError, Message: "ASR start failed"} + return + } + s.sendAudio = sa + s.cleanup = cl + s.active = true + s.log.Info("recording started") +} +func (h *Handler) handleStop(s *session) { + if !s.active { + return + } + s.cleanupASR() + s.sendAudio = nil + s.active = false + s.previewMu.Lock() + finalText := s.previewText + s.previewText = "" + s.previewMu.Unlock() + if finalText != "" && h.pasteFunc != nil { + if err := h.pasteFunc(finalText); err != nil { + s.log.Error("auto-paste failed", "err", err) + } else { + s.resultCh <- ServerMsg{Type: MsgPasted} + } + } + s.log.Info("recording stopped") +} +func (h *Handler) handlePaste(s *session, text string) { + if text == "" { + return + } + if h.pasteFunc != nil { + if err := h.pasteFunc(text); err != nil { + s.log.Error("paste failed", "err", err) + s.resultCh <- ServerMsg{Type: MsgError, Message: "paste failed"} + } else { + s.resultCh <- ServerMsg{Type: MsgPasted} + } + } +} +func (s *session) cleanupASR() { + if s.cleanup != nil { + s.cleanup() + s.cleanup = nil + } } \ No newline at end of file diff --git a/main.go b/main.go index 43a4067..8581781 100644 --- a/main.go +++ b/main.go @@ -22,99 +22,133 @@ var webFS embed.FS var version = "dev" func main() { + initLogger() + slog.Info("VoicePaste", "version", version) + cfg := mustLoadConfig() + config.WatchAndReload("") + initClipboard() + lanIPs := mustDetectLANIPs() + lanIP := lanIPs[0] + tlsResult, scheme := setupTLS(cfg, lanIP) + printBanner(cfg, tlsResult, lanIPs, scheme) + srv := createServer(cfg, lanIP, tlsResult) + runWithGracefulShutdown(srv) +} + +func initLogger() { slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ Level: slog.LevelInfo, }))) +} - slog.Info("VoicePaste", "version", version) - - // Load config +func mustLoadConfig() config.Config { cfg, err := config.Load("") if err != nil { slog.Error("failed to load config", "error", err) os.Exit(1) } + return cfg +} - // Start config hot-reload watcher - config.WatchAndReload("") - - // Initialize clipboard +func initClipboard() { if err := paste.Init(); err != nil { slog.Warn("clipboard init failed, paste will be unavailable", "err", err) } - // Detect LAN IPs +} + +func mustDetectLANIPs() []string { lanIPs, err := server.GetLANIPs() if err != nil { slog.Error("failed to detect LAN IP", "error", err) os.Exit(1) } - lanIP := lanIPs[0] // Use first IP for TLS and server binding + return lanIPs +} - // Read token from config (empty = no auth required) - token := cfg.Security.Token - - // TLS setup - var tlsResult *vpTLS.Result - scheme := "http" - host := lanIP - if cfg.Server.TLSAuto { - var err error - tlsResult, err = vpTLS.GetTLSConfig(lanIP) - if err != nil { - slog.Error("TLS setup failed", "error", err) - os.Exit(1) - } - scheme = "https" - host = tlsResult.Host +func setupTLS(cfg config.Config, lanIP string) (*vpTLS.Result, string) { + if !cfg.Server.TLSAuto { + return nil, "http" } + tlsResult, err := vpTLS.GetTLSConfig(lanIP) + if err != nil { + slog.Error("TLS setup failed", "error", err) + os.Exit(1) + } + return tlsResult, "https" +} - // Print connection info +func printBanner(cfg config.Config, tlsResult *vpTLS.Result, lanIPs []string, scheme string) { fmt.Println() fmt.Println("╔══════════════════════════════════════╗") fmt.Println("║ VoicePaste 就绪 ║") fmt.Println("╚══════════════════════════════════════╝") fmt.Println() - // Print all accessible addresses + printAddresses(cfg, tlsResult, lanIPs, scheme) + printCertInfo(tlsResult, cfg.Server.TLSAuto) + printAuthInfo(cfg.Security.Token) + fmt.Println() + fmt.Println(" 在手机浏览器中打开上方地址") + fmt.Println(" 按 Ctrl+C 停止服务") + fmt.Println() +} + +func printAddresses(cfg config.Config, tlsResult *vpTLS.Result, lanIPs []string, scheme string) { + token := cfg.Security.Token if len(lanIPs) == 1 { + host := lanIP(tlsResult, lanIPs[0]) fmt.Printf(" 地址: %s\n", buildURL(scheme, host, cfg.Server.Port, token)) - } else { - fmt.Println(" 地址:") - for _, ip := range lanIPs { - h := ip - if tlsResult != nil && tlsResult.AnyIP { - h = vpTLS.AnyIPHost(ip) - } - fmt.Printf(" - %s\n", buildURL(scheme, h, cfg.Server.Port, token)) - } + return } - if tlsResult != nil && tlsResult.AnyIP { + fmt.Println(" 地址:") + for _, ip := range lanIPs { + host := lanIP(tlsResult, ip) + fmt.Printf(" - %s\n", buildURL(scheme, host, cfg.Server.Port, token)) + } +} + +func lanIP(tlsResult *vpTLS.Result, ip string) string { + if tlsResult != nil { + return vpTLS.AnyIPHost(ip) + } + return ip +} + +func printCertInfo(tlsResult *vpTLS.Result, tlsAuto bool) { + if tlsResult != nil { fmt.Println(" 证书: AnyIP(浏览器信任)") - } else if cfg.Server.TLSAuto { - fmt.Println(" 证书: 自签名(浏览器会警告)") + } else if tlsAuto { + fmt.Println(" 证书: 配置错误(TLS 启用但未获取证书)") } +} + +func printAuthInfo(token string) { if token != "" { fmt.Println(" 认证: 已启用") } else { fmt.Println(" 认证: 未启用(无需 token)") } - fmt.Println() - fmt.Println(" 在手机浏览器中打开上方地址") - fmt.Println(" 按 Ctrl+C 停止服务") - fmt.Println() - // Create and start server +} + +func createServer(cfg config.Config, lanIP string, tlsResult *vpTLS.Result) *server.Server { webContent, _ := fs.Sub(webFS, "web/dist") - var serverTLSCfg *crypto_tls.Config + var tlsConfig *crypto_tls.Config if tlsResult != nil { - serverTLSCfg = tlsResult.Config + tlsConfig = tlsResult.Config } - srv := server.New(token, lanIP, webContent, serverTLSCfg) - // Build ASR factory from config + srv := server.New(cfg.Security.Token, lanIP, webContent, tlsConfig) + asrFactory := buildASRFactory(cfg) + wsHandler := ws.NewHandler(cfg.Security.Token, paste.Paste, asrFactory) + wsHandler.Register(srv.App()) + return srv +} + +func buildASRFactory(cfg config.Config) func(chan<- ws.ServerMsg) (func([]byte), func(), error) { asrCfg := asr.Config{ AppID: cfg.Doubao.AppID, AccessToken: cfg.Doubao.AccessToken, - ResourceID: cfg.Doubao.ResourceID, + ResourceID: cfg.Doubao.ResourceID, } - asrFactory := func(resultCh chan<- ws.ServerMsg) (func([]byte), func(), error) { + return func(resultCh chan<- ws.ServerMsg) (func([]byte), func(), error) { client, err := asr.Dial(asrCfg, resultCh) if err != nil { return nil, nil, err @@ -129,12 +163,9 @@ func main() { } return sendAudio, cleanup, nil } +} - // Register WebSocket handler - wsHandler := ws.NewHandler(token, paste.Paste, asrFactory) - wsHandler.Register(srv.App()) - - // Graceful shutdown +func runWithGracefulShutdown(srv *server.Server) { go func() { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) @@ -142,7 +173,6 @@ func main() { slog.Info("shutting down...") srv.Shutdown() }() - if err := srv.Start(); err != nil { slog.Error("server error", "error", err) os.Exit(1) diff --git a/web/app.ts b/web/app.ts index 4c09bce..55f4b28 100644 --- a/web/app.ts +++ b/web/app.ts @@ -29,7 +29,7 @@ interface AppState { connected: boolean; recording: boolean; pendingStart: boolean; - startCancelled: boolean; + abortController: AbortController | null; audioCtx: AudioContext | null; workletNode: AudioWorkletNode | null; stream: MediaStream | null; @@ -65,7 +65,7 @@ const state: AppState = { connected: false, recording: false, pendingStart: false, - startCancelled: false, + abortController: null, audioCtx: null, workletNode: null, stream: null, @@ -128,20 +128,19 @@ function connectWS(): void { micBtn.disabled = false; }; ws.onmessage = (e: MessageEvent) => handleServerMsg(e.data); - ws.onclose = () => { - state.connected = false; - state.ws = null; - micBtn.disabled = true; - if (state.recording) stopRecording(); - // Clean up pending async start on disconnect - if (state.pendingStart) { - state.pendingStart = false; - state.startCancelled = true; - micBtn.classList.remove("recording"); - } - setStatus("disconnected", "已断开"); - scheduleReconnect(); - }; +ws.onclose = () => { + state.connected = false; + state.ws = null; + micBtn.disabled = true; + if (state.recording) stopRecording(); + if (state.pendingStart) { + state.abortController?.abort(); + state.pendingStart = false; + micBtn.classList.remove("recording"); + } + setStatus("disconnected", "已断开"); + scheduleReconnect(); +}; ws.onerror = () => ws.close(); state.ws = ws; } @@ -199,25 +198,14 @@ function setPreview(text: string, isFinal: boolean): void { previewBox.classList.toggle("active", !isFinal); } function showToast(msg: string): void { - let toast = document.getElementById("toast"); - if (!toast) { - toast = document.createElement("div"); - toast.id = "toast"; - toast.style.cssText = - "position:fixed;bottom:calc(100px + var(--safe-bottom,0px));left:50%;" + - "transform:translateX(-50%);background:#222;color:#eee;padding:8px 18px;" + - "border-radius:20px;font-size:14px;z-index:999;opacity:0;transition:opacity .3s;"; - document.body.appendChild(toast); - } + const toast = q("#toast"); toast.textContent = msg; - toast.style.opacity = "1"; - clearTimeout( - (toast as HTMLElement & { _timer?: ReturnType })._timer, - ); - (toast as HTMLElement & { _timer?: ReturnType })._timer = - setTimeout(() => { - toast.style.opacity = "0"; - }, 2000); + toast.classList.add("show"); + const timer = (toast as HTMLElement & { _timer?: ReturnType })._timer; + if (timer) clearTimeout(timer); + (toast as HTMLElement & { _timer?: ReturnType })._timer = setTimeout(() => { + toast.classList.remove("show"); + }, 2000); } // ── Audio pipeline ── async function initAudio(): Promise { @@ -234,19 +222,19 @@ async function initAudio(): Promise { async function startRecording(): Promise { if (state.recording || state.pendingStart) return; state.pendingStart = true; - state.startCancelled = false; + const abortController = new AbortController(); + state.abortController = abortController; try { await initAudio(); - if (state.startCancelled) { + if (abortController.signal.aborted) { state.pendingStart = false; return; } const audioCtx = state.audioCtx as AudioContext; - // Ensure AudioContext is running (may suspend between recordings) if (audioCtx.state === "suspended") { await audioCtx.resume(); } - if (state.startCancelled) { + if (abortController.signal.aborted) { state.pendingStart = false; return; } @@ -257,7 +245,7 @@ async function startRecording(): Promise { channelCount: 1, }, }); - if (state.startCancelled) { + if (abortController.signal.aborted) { stream.getTracks().forEach((t) => { t.stop(); }); @@ -275,34 +263,33 @@ async function startRecording(): Promise { }; source.connect(worklet); worklet.port.postMessage({ command: "start" }); - // Don't connect worklet to destination (no playback) state.workletNode = worklet; state.pendingStart = false; + state.abortController = null; state.recording = true; sendJSON({ type: "start" }); micBtn.classList.add("recording"); setPreview("", false); } catch (err) { state.pendingStart = false; + state.abortController = null; showToast(`麦克风错误: ${(err as Error).message}`); } } function stopRecording(): void { - // Cancel pending async start if still initializing if (state.pendingStart) { - state.startCancelled = true; + state.abortController?.abort(); + state.abortController = null; micBtn.classList.remove("recording"); return; } if (!state.recording) return; state.recording = false; - // Stop worklet if (state.workletNode) { state.workletNode.port.postMessage({ command: "stop" }); state.workletNode.disconnect(); state.workletNode = null; } - // Stop mic stream if (state.stream) { state.stream.getTracks().forEach((t) => { t.stop(); diff --git a/web/index.html b/web/index.html index 07caf99..2ff78b5 100644 --- a/web/index.html +++ b/web/index.html @@ -42,6 +42,7 @@

暂无记录

+
diff --git a/web/style.css b/web/style.css index 5143c2f..9fb99b7 100644 --- a/web/style.css +++ b/web/style.css @@ -272,3 +272,22 @@ header h1 { background: var(--border); border-radius: 2px; } +/* Toast */ +.toast { + position: fixed; + bottom: calc(100px + var(--safe-bottom, 0px)); + left: 50%; + transform: translateX(-50%); + background: #222; + color: #eee; + padding: 8px 18px; + border-radius: 20px; + font-size: 14px; + z-index: 999; + opacity: 0; + transition: opacity 0.3s; + pointer-events: none; +} +.toast.show { + opacity: 1; +}