[Quicker] 语音输入 - 源码归档

动作:语音输入

通过 Quicker 鉴权调用讯飞语音识别 API,实时录音转文字并自动打字。支持动态修正和智能退格纠错。

更新时间:2025-12-31 15:47
原始文件:VoiceInput.cs

核心代码

using System;
using System.Text;
using System.Net.Http;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using System.Windows.Threading;
using System.Windows.Forms;
using System.Windows.Controls;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NAudio.Wave;
using Quicker.Public;
using Application = System.Windows.Application;
using Clipboard = System.Windows.Clipboard;
using TextBox = System.Windows.Controls.TextBox;
using TextBlock = System.Windows.Controls.TextBlock;
using SendKeys = System.Windows.Forms.SendKeys;
using Color = System.Windows.Media.Color;
using ColorConverter = System.Windows.Media.ColorConverter;

//css_reference C:\Program Files\Quicker\NAudio.dll;
//css_reference Newtonsoft.Json.dll;
//css_reference System.Windows.Forms.dll;
//css_reference System.Drawing.dll;

public static void Exec(IStepContext context)
{
    bool createdNew;
    using (var mutex = new System.Threading.Mutex(true, "Quicker_VoiceInput_Unique_Mutex", out createdNew))
    {
        if (!createdNew)
        {
            System.Windows.MessageBox.Show("语音输入动作已在运行中。您可以通过系统托盘图标管理它。");
            return;
        }
        var manager = new VoiceInputManager(context);
        // 从 Quicker 变量动态获取 Token
        manager._quickerToken = context.GetVarValue("quicker_token")?.ToString() ?? "";
        if (string.IsNullOrEmpty(manager._quickerToken)) {
            System.Windows.MessageBox.Show("错误:未能在 Quicker 动作变量中找到 'quicker_token'。\n请点击动作“编辑” - “变量”,添加该变量并填入您的 Token。");
            return;
        }
        manager.Run();
    }
}

public class VoiceInputManager
{
    private IStepContext _context;
    private string _logPath = @"f:\桌面\语音输入\debug_log.txt"; // 统一日志路径
    private Window _win;
    private ClientWebSocket _ws;
    private WaveInEvent _waveIn;
    private NotifyIcon _trayIcon;
    private ContextMenuStrip _trayMenu;
    
    private SortedDictionary<int, string> _resultsDict = new SortedDictionary<int, string>();
    private bool _isRunning = true;
    private bool _isRecording = false;
    private bool _isStarting = false;
    private object _lock = new object();
    private SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1);
    private CancellationTokenSource _sessionCts;
    private DateTime _startPressTime;
    
    // 音频缓冲:解决连接耗时导致的丢音问题
    private List<byte> _audioBuffer = new List<byte>();
    private bool _isWsReady = false;
    private System.Windows.Controls.ProgressBar _volumeBar; // 能量条

    // 鉴权缓存
    private string _cachedAuthUrl = null;
    private DateTime _lastAuthTime = DateTime.MinValue;
    
    // UI 控件引用
    private TextBlock _statusLabel;
    private TextBox _resultBox;
    private System.Windows.Controls.Button _btn;

    [System.Runtime.InteropServices.DllImport("user32.dll")]
    public static extern short GetAsyncKeyState(int vKey);
    
    [System.Runtime.InteropServices.DllImport("user32.dll")]
    public static extern bool GetCursorPos(out System.Drawing.Point lpPoint);

    private const int VK_MENU = 0x12;  // Alt 键
    private const int VK_LSHIFT = 0xA0; // 左 Shift 键
    private const int VK_ESCAPE = 0x1B; // Esc 键
    private bool _lastKeyState = false;
    private bool _lastEscState = false; // 用于 Esc 状态去重
    private bool _isLocked = false;    // 是否进入编辑锁定模式
    private TextBlock _hintLabel;      // 快捷键提示

    // 配置 (Token 将从变量加载)
    public string _quickerToken = ""; 
    private string _appId = "3e2c9c06";
    private string _apiHost = "iat-api.xfyun.cn";

    public VoiceInputManager(IStepContext context)
    {
        _context = context;
    }

    public void Run()
    {
        // 启动即重置日志,确保每次运行都是干净的
        try { System.IO.File.WriteAllText(_logPath, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] ===== 语音输入 Pro 启动 (重置模式) =====\r\n"); } catch { }
        
        // 1. 初始化托盘
        InitTrayIcon();

        // 2. 初始化窗口 (必须在 STA)
        Application.Current.Dispatcher.Invoke(() => InitializeWindow());
        
        // 预热:深度预热连接和硬件,解决冷启动 11 秒延迟问题
        _ = Task.Run(async () => {
            try {
                await Task.Delay(500);
                System.Net.ServicePointManager.SecurityProtocol |= (SecurityProtocolType)3072 | (SecurityProtocolType)768 | (SecurityProtocolType)192; 
                LogToFile("开始深度预热 (DNS/TLS/硬件)...");
                
                // 1. 强制进行一次物理音频热启动
                try {
                    using (var hot = new WaveInEvent { WaveFormat = new WaveFormat(16000, 1) }) {
                        hot.StartRecording();
                        Thread.Sleep(200);
                        hot.StopRecording();
                    }
                } catch { }

                // 2. 提前获取 URL 并发起一次握手预建立 TLS 隧道
                string url = await GetAuthUrl(); 
                if (url != null) {
                    using (var ws = new ClientWebSocket()) {
                        var cts = new CancellationTokenSource(3000);
                        await ws.ConnectAsync(new Uri(url), cts.Token);
                        // 连接即关闭,仅为解析 DNS 和建立 TCP 缓存
                    }
                }
                LogToFile("深度预热完成。");
            } catch (Exception ex) {
                LogToFile($"深度预热微警示: {ex.Message}");
            }
        });

        // 3. 消息循环
        while (_isRunning)
        {
            // 监测热键状态 (推荐使用左 Alt)
            bool isKeyDown = (GetAsyncKeyState(VK_MENU) & 0x8000) != 0;
            bool isEscDown = (GetAsyncKeyState(VK_ESCAPE) & 0x8000) != 0;
            bool isShiftDown = (GetAsyncKeyState(VK_LSHIFT) & 0x8000) != 0;

            if (isEscDown && !_lastEscState) {
                _lastEscState = true;
                if (_isRecording || _isStarting) {
                    LogToFile("用户按下 Esc:强制中断并销毁流程");
                    _win.Dispatcher.Invoke(() => {
                        _win.Hide();
                        _resultBox.Text = ""; // 清空缓冲区
                    });
                    _ = StopProcess(); // 彻底停止一切录音和发送
                }
            } else if (!isEscDown) {
                _lastEscState = false;
            }

            if (isKeyDown && !_lastKeyState) {
                _lastKeyState = true; _isLocked = false;
                LogToFile("PTT 按下");
                _win.Dispatcher.BeginInvoke(new Action(() => { _ = StartProcess(); }));
            } 
            else if (isKeyDown && _lastKeyState && isShiftDown && !_isLocked) {
                // 核心:录音过程中按下 Shift 进入锁定模式
                _isLocked = true;
                LogToFile("同步锁定模式触发");
                _win.Dispatcher.Invoke(() => {
                    _statusLabel.Text = " 锁定";
                    _statusLabel.Foreground = System.Windows.Media.Brushes.Cyan;
                    _volumeBar.Foreground = System.Windows.Media.Brushes.DimGray;
                });
            }
            else if (!isKeyDown && _lastKeyState) {
                _lastKeyState = false;
                LogToFile("PTT 松开");
                _win.Dispatcher.BeginInvoke(new Action(() => { _ = StopAndSend(); }));
            }

            System.Windows.Forms.Application.DoEvents();
            Thread.Sleep(20); 
        }

        // 4. 资源清理
        Cleanup();
    }

    private void InitTrayIcon()
    {
        _trayIcon = new NotifyIcon();
        try {
            string baseDir = System.IO.Path.GetDirectoryName(_logPath);
            string cachePath = System.IO.Path.Combine(baseDir, "tray_icon.png");
            byte[] iconData = null;

            if (System.IO.File.Exists(cachePath)) {
                iconData = System.IO.File.ReadAllBytes(cachePath);
                LogToFile("从本地缓存加载图标");
            } else {
                using (var wc = new System.Net.WebClient()) {
                    iconData = wc.DownloadData("https://files.getquicker.net/_icons/D49D91C62021C9686B330970F69CA0E348740B1A.png");
                    System.IO.File.WriteAllBytes(cachePath, iconData); // 写入缓存
                }
                LogToFile("下载并保存图标缓存");
            }

            if (iconData != null) {
                using (var ms = new System.IO.MemoryStream(iconData)) {
                    using (var bitmap = new System.Drawing.Bitmap(ms)) {
                        _trayIcon.Icon = System.Drawing.Icon.FromHandle(bitmap.GetHicon());
                    }
                }
            }
        } catch (Exception ex) {
            LogToFile("图标加载失败: " + ex.Message);
            _trayIcon.Icon = System.Drawing.SystemIcons.Application;
        }
        
        _trayIcon.Text = "极速语音 Pro - 运行中";
        _trayIcon.Visible = true;
        
        _trayMenu = new ContextMenuStrip();
        _trayMenu.Items.Add("退出", null, (s, e) => {
            LogToFile("用户选择从托盘退出。");
            _isRunning = false;
        });
        _trayIcon.ContextMenuStrip = _trayMenu;
    }

    private void ShowWindow()
    {
        Application.Current.Dispatcher.Invoke(() => {
            if (_win != null) {
                _win.Show();
                _win.Activate();
                _win.WindowState = WindowState.Normal;
            }
        });
    }
    private void InitializeWindow()
    {
        _win = new Window()
        {
            Title = "极速语音",
            Width = 450,
            Height = 100, // 初始厚度预设
            MinHeight = 60,
            Topmost = true,
            WindowStyle = WindowStyle.None,
            AllowsTransparency = true,
            Background = System.Windows.Media.Brushes.Transparent,
            WindowStartupLocation = WindowStartupLocation.Manual,
            SizeToContent = SizeToContent.Height,
            ShowInTaskbar = false
        };
        
        // 核心技巧:闪现一下以触发 WPF 的 DPI 换算和 ActualHeight 计算
        _win.Opacity = 0;
        _win.Show();
        _win.Hide();
        _win.Opacity = 1;

        // 阴影效果
        var shadow = new System.Windows.Media.Effects.DropShadowEffect {
            BlurRadius = 20, Color = System.Windows.Media.Colors.Black,
            Opacity = 0.6, ShadowDepth = 5, Direction = 270
        };

        var border = new Border {
            Background = (System.Windows.Media.Brush)new System.Windows.Media.BrushConverter().ConvertFromString("#1D1D1F"),
            CornerRadius = new CornerRadius(16),
            Padding = new Thickness(15),
            BorderThickness = new Thickness(1),
            BorderBrush = (System.Windows.Media.Brush)new System.Windows.Media.BrushConverter().ConvertFromString("#333333"),
            Effect = shadow
        };

        var grid = new Grid();
        grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
        grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
        grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(3) }); // 能量条位置

        // 状态胶囊布局容器
        var statusWrap = new Border {
            Background = (System.Windows.Media.Brush)new System.Windows.Media.BrushConverter().ConvertFromString("#33000000"),
            CornerRadius = new CornerRadius(10),
            Padding = new Thickness(10, 2, 10, 2),
            HorizontalAlignment = System.Windows.HorizontalAlignment.Left,
            Margin = new Thickness(0, 0, 0, 8)
        };

        var statusStack = new StackPanel { Orientation = System.Windows.Controls.Orientation.Horizontal };
        _statusLabel = new TextBlock { 
            Text = "准备入座", 
            Foreground = System.Windows.Media.Brushes.DarkGray, 
            FontSize = 12, FontWeight = FontWeights.Bold 
        };
        _hintLabel = new TextBlock { 
            Text = " [Esc]取消  [Shift]锁定", 
            Foreground = (System.Windows.Media.Brush)new System.Windows.Media.BrushConverter().ConvertFromString("#888888"),
            FontSize = 10, Margin = new Thickness(6, 1, 0, 0),
            VerticalAlignment = VerticalAlignment.Center
        };
        statusStack.Children.Add(_statusLabel);
        statusStack.Children.Add(_hintLabel);
        statusWrap.Child = statusStack;
        Grid.SetRow(statusWrap, 0);

        _resultBox = new TextBox { 
            Text = "", TextWrapping = TextWrapping.Wrap, 
            IsReadOnly = false, AcceptsReturn = true,
            Background = System.Windows.Media.Brushes.Transparent,
            Foreground = System.Windows.Media.Brushes.White, BorderThickness = new Thickness(0),
            FontSize = 16, Padding = new Thickness(2), CaretBrush = System.Windows.Media.Brushes.DodgerBlue,
            VerticalScrollBarVisibility = ScrollBarVisibility.Disabled // 让它自动撑开
        };
        Grid.SetRow(_resultBox, 1);

        _volumeBar = new System.Windows.Controls.ProgressBar {
            Height = 3, Background = System.Windows.Media.Brushes.Transparent,
            Foreground = (System.Windows.Media.Brush)new System.Windows.Media.BrushConverter().ConvertFromString("#007AFF"),
            BorderThickness = new Thickness(0), Minimum = 0, Maximum = 100, Value = 0,
            Margin = new Thickness(0, 10, 0, 0)
        };
        Grid.SetRow(_volumeBar, 2);

        grid.Children.Add(statusWrap);
        grid.Children.Add(_resultBox);
        grid.Children.Add(_volumeBar);
        border.Child = grid;
        _win.Content = border;

        _win.Closing += (s, e) => { if (_isRunning) { e.Cancel = true; _win.Hide(); } };
        _win.MouseDown += (s, e) => { if (e.LeftButton == System.Windows.Input.MouseButtonState.Pressed) _win.DragMove(); };
        
        // 注意:这里删除了 _win.Show(),初始不显示
    }

    private async Task StopAndSend()
    {
        if (!_isRecording && !_isStarting) return;
        
        string finalText = "";
        bool isLockedMode = _isLocked; // 记录当前是否是锁定模式

        _win.Dispatcher.Invoke(() => { 
            finalText = _resultBox.Text;
            _win.Hide(); // 无论是否锁定,都必须隐藏窗口以交还焦点进行粘贴
        });

        _isRecording = false; 

        if (!string.IsNullOrEmpty(finalText)) {
            LogToFile($"PTT 模式: {(isLockedMode ? "只粘贴" : "粘贴并发送")}");
            _ = Task.Run(() => {
                Thread.Sleep(20); 
                _win.Dispatcher.Invoke(() => {
                    try { 
                        Clipboard.SetText(finalText); 
                        if (!isLockedMode) {
                            // 极速模式:粘贴 + 回车
                            SendKeys.SendWait("^v{ENTER}"); 
                        } else {
                            // 锁定模式:只粘贴
                            SendKeys.SendWait("^v"); 
                        }
                    } catch { }
                });
            });
        }

        // 4. 后台静默处理资源回收
        _ = StopProcess(); 
    }

    private async Task ToggleRecording()
    {
        if (!_isRecording) await StartProcess();
        else await StopAndSend();
    }

    private void LogToFile(string msg)
    {
        try {
            var fi = new System.IO.FileInfo(_logPath);
            if (fi.Exists && fi.Length > 2 * 1024 * 1024) { // 超过 2MB 自动重置
                System.IO.File.WriteAllText(_logPath, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] 日志过大,已自动重置\r\n");
            }
            System.IO.File.AppendAllText(_logPath, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {msg}\r\n");
        } catch { }
    }

    private void UpdatePosition()
    {
        System.Drawing.Point mousePos;
        GetCursorPos(out mousePos);
        var source = PresentationSource.FromVisual(_win);
        double dpiX = source?.CompositionTarget != null ? source.CompositionTarget.TransformToDevice.M11 : 1.0;
        double dpiY = source?.CompositionTarget != null ? source.CompositionTarget.TransformToDevice.M22 : 1.0;
        var screen = System.Windows.Forms.Screen.FromPoint(mousePos).WorkingArea;
        _win.UpdateLayout();
        double winWidth = _win.Width;
        double winHeight = _win.ActualHeight > 60 ? _win.ActualHeight : 100;
        double targetLeft = (mousePos.X / dpiX) - (winWidth / 2);
        double targetTop = (mousePos.Y / dpiY) - winHeight - 15;
        double sLeft = screen.Left / dpiX, sRight = screen.Right / dpiX;
        double sTop = screen.Top / dpiY, sBottom = screen.Bottom / dpiY;
        if (targetLeft < sLeft) targetLeft = sLeft + 10;
        if (targetLeft + winWidth > sRight) targetLeft = sRight - winWidth - 10;
        if (targetTop < sTop) targetTop = (mousePos.Y / dpiY) + 20; 
        if (targetTop + winHeight > sBottom) targetTop = sBottom - winHeight - 10;
        _win.Left = targetLeft; _win.Top = targetTop;
    }

    private async Task StartProcess()
    {
        if (_isStarting || _isRecording) return;
        _isStarting = true; _isWsReady = false;
        _startPressTime = DateTime.Now;
        _sessionCts = new CancellationTokenSource();
        _audioBuffer.Clear();

        _win.Dispatcher.Invoke(() => {
            UpdatePosition();
            _win.Show(); _win.Activate();
            _statusLabel.Text = "● 准备中";
            _statusLabel.Foreground = System.Windows.Media.Brushes.Gold; 
            _resultBox.Text = ""; 
        });
        
        lock (_lock) _resultsDict.Clear();

        try {
            _waveIn = new WaveInEvent { WaveFormat = new WaveFormat(16000, 1) };
            _waveIn.DataAvailable += async (s, a) => {
                double sum = 0;
                for (int i = 0; i < a.BytesRecorded; i += 2) {
                    short sample = BitConverter.ToInt16(a.Buffer, i);
                    sum += sample * sample;
                }
                double rms = Math.Sqrt(sum / (a.BytesRecorded / 2));
                double level = Math.Min(100, (rms / 327.68) * 5);
                _win.Dispatcher.BeginInvoke(new Action(() => _volumeBar.Value = level));

                byte[] data = a.Buffer.Take(a.BytesRecorded).ToArray();
                if (!_isWsReady) { lock(_audioBuffer) _audioBuffer.AddRange(data); }
                else { await SendAudioChunk(data, 1); }
            };
            _waveIn.StartRecording();
        } catch (Exception ex) {
            LogToFile("音频硬件错误: " + ex.Message);
            _isStarting = false; return;
        }

        int retryCount = 0;
        int maxRetries = 2;
        while (retryCount < maxRetries) {
            var sw = System.Diagnostics.Stopwatch.StartNew();
            try {
                LogToFile($"[尝试 {retryCount+1}] 开始连接...");
                string url = await GetAuthUrl();
                
                _ws = new ClientWebSocket();
                using (var connectCts = CancellationTokenSource.CreateLinkedTokenSource(_sessionCts.Token)) {
                    // 【核心】硬超时实现:Task.WhenAny 确保 2.5秒必须有结果
                    var connectTask = _ws.ConnectAsync(new Uri(url), connectCts.Token);
                    if (await Task.WhenAny(connectTask, Task.Delay(2500, connectCts.Token)) != connectTask) {
                        throw new TimeoutException("WebSocket 握手硬超时");
                    }
                    await connectTask; // 如果已完成,等待确认异常或结果
                }
                
                LogToFile($"[尝试 {retryCount+1}] 连接成功,耗时 {sw.ElapsedMilliseconds}ms");
                var startFrame = new {
                    common = new { app_id = _appId },
                    business = new { language = "zh_cn", domain = "iat", accent = "mandarin", vad_eos = 60000, dwa = "wpgs", ptt = 1, rlang = "zh-cn" },
                    data = new { status = 0, format = "audio/L16;rate=16000", encoding = "raw" }
                };
                await SendRaw(JsonConvert.SerializeObject(startFrame));
                
                byte[] buffered;
                lock(_audioBuffer) { buffered = _audioBuffer.ToArray(); _audioBuffer.Clear(); }
                if (buffered.Length > 0) await SendAudioChunk(buffered, 1);
                
                _isWsReady = true; _isRecording = true; _isStarting = false;
                _win.Dispatcher.Invoke(() => { 
                    _statusLabel.Text = "● 倾听中"; 
                    _statusLabel.Foreground = System.Windows.Media.Brushes.SpringGreen; 
                });
                _ = Task.Run(async () => await ReceiveLoop());
                return;
            } catch (Exception ex) {
                sw.Stop();
                if (_sessionCts.IsCancellationRequested) break;
                retryCount++;
                _cachedAuthUrl = null; 
                LogToFile($"[尝试 {retryCount}] 失败 ({sw.ElapsedMilliseconds}ms): {ex.Message}");
                if (retryCount >= maxRetries) break;
                await Task.Delay(50); // 极短重试间隔
            }
        }
        
        _isStarting = false;
        if (!_isRecording && !_sessionCts.IsCancellationRequested) 
            _win.Dispatcher.Invoke(() => { _statusLabel.Text = "连接超时,请重试"; });
    }

    private async Task SendAudioChunk(byte[] data, int status)
    {
        if (_ws?.State != WebSocketState.Open) return;
        var chunk = new { data = new { status = status, format = "audio/L16;rate=16000", encoding = "raw", audio = Convert.ToBase64String(data) } };
        await SendRaw(JsonConvert.SerializeObject(chunk));
    }

    private async Task SendRaw(string json)
    {
        try {
            byte[] bytes = Encoding.UTF8.GetBytes(json);
            await _sendLock.WaitAsync();
            if (_ws?.State == WebSocketState.Open) {
                await _ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, CancellationToken.None);
            }
        } catch { } finally { _sendLock.Release(); }
    }

    private async Task ReceiveLoop()
    {
        byte[] buffer = new byte[1024 * 15];
        while (_ws?.State == WebSocketState.Open) {
            try {
                var result = await _ws.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
                if (result.MessageType == WebSocketMessageType.Close) break;
                string msg = Encoding.UTF8.GetString(buffer, 0, result.Count);
                ProcessResult(msg);
            } catch { break; }
        }
    }

    private void ProcessResult(string json)
    {
        try {
            var resp = JObject.Parse(json);
            if (resp["code"]?.Value<int>() != 0) return;
            var resObj = resp["data"]?["result"];
            if (resObj == null) return;
            int sn = resObj["sn"].Value<int>();
            string pgs = resObj["pgs"]?.ToString();
            StringBuilder sb = new StringBuilder();
            foreach (var wsItem in resObj["ws"]) foreach (var cw in wsItem["cw"]) sb.Append(cw["w"].ToString());
            string text = sb.ToString();

            lock (_lock) {
                if (!_isRecording && !_isStarting) return; // 如果已经开始发送了,就不再处理后续结果
                if (pgs == "rpl") {
                    var rg = resObj["rg"];
                    for (int i = rg[0].Value<int>(); i <= rg[1].Value<int>(); i++) _resultsDict.Remove(i);
                }
                _resultsDict[sn] = text;
                string fullText = string.Join("", _resultsDict.Values);
                
                _win.Dispatcher.BeginInvoke(new Action(() => {
                    if (!_isRecording && !_isStarting) return; 
                    _resultBox.Text = fullText;
                    _resultBox.ScrollToEnd();
                    _resultBox.CaretIndex = _resultBox.Text.Length;
                }));
            }
        } catch { }
    }

    private void SmartType(string newText) { } // 已弃用,逻辑已移至 StopAndSend

    private async Task<string> GetAuthUrl()
    {
        // 缩短缓存至 60 秒,保证签名的及时性
        if (!string.IsNullOrEmpty(_cachedAuthUrl) && (DateTime.Now - _lastAuthTime).TotalSeconds < 60) {
            return _cachedAuthUrl;
        }

        try {
            LogToFile("请求 Quicker 远程鉴权...");
            System.Net.ServicePointManager.SecurityProtocol |= System.Net.SecurityProtocolType.Tls12;
            using (var client = new HttpClient()) {
                client.DefaultRequestHeaders.Add("Authorization", "Bearer " + _quickerToken);
                var response = await client.PostAsync("https://api.getquicker.net/api/speech/GenerateAsrSignature", new StringContent("{}", Encoding.UTF8, "application/json"));
                if (response.IsSuccessStatusCode) {
                    var json = JObject.Parse(await response.Content.ReadAsStringAsync());
                    if (json["isSuccess"]?.Value<bool>() == true) {
                        var data = json["data"];
                        string authStr = (string)data["authorization"];
                        string nowIso = (string)data["now"];
                        string authBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(authStr));
                        string naiveTime = nowIso.Length >= 19 ? nowIso.Substring(0, 19) : nowIso;
                        
                        DateTime dt = DateTime.Parse(naiveTime.Replace("T", " "));
                        string dateRfc = dt.ToString("ddd, dd MMM yyyy HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture) + " GMT";
                        string query = string.Format("authorization={0}&date={1}&host={2}", 
                            System.Net.WebUtility.UrlEncode(authBase64), System.Net.WebUtility.UrlEncode(dateRfc), System.Net.WebUtility.UrlEncode(_apiHost));
                        
                        _cachedAuthUrl = "wss://" + _apiHost + "/v2/iat?" + query;
                        _lastAuthTime = DateTime.Now;
                        return _cachedAuthUrl;
                    }
                }
            }
        } catch (Exception ex) { LogToFile("鉴权异常: " + ex); }
        return null;
    }

    private async Task StopProcess()
    {
        _sessionCts?.Cancel();
        
        if ((DateTime.Now - _startPressTime).TotalMilliseconds < 200 && _isStarting) {
            _isStarting = false; _isRecording = false; _isWsReady = false; return;
        }

        LogToFile("后台资源回收开始...");
        _isRecording = false; _isStarting = false; _isWsReady = false;
        
        // 1. 停止麦克风
        if (_waveIn != null) {
            try { _waveIn.StopRecording(); } catch { }
            _waveIn.Dispose();
            _waveIn = null;
        }

        // 2. 并行处理 WebSocket 关闭,不阻塞主流程
        if (_ws != null) {
            var oldWs = _ws; _ws = null;
            _ = Task.Run(async () => {
                try {
                    if (oldWs.State == WebSocketState.Open) {
                        var lastFrame = new { data = new { status = 2, audio = "" } };
                        byte[] bytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(lastFrame));
                        await oldWs.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, CancellationToken.None);
                        await Task.Delay(50);
                        await oldWs.CloseAsync(WebSocketCloseStatus.NormalClosure, "done", CancellationToken.None);
                    }
                } catch { } finally { oldWs?.Dispose(); }
            });
        }

        _win.Dispatcher.Invoke(() => {
            _statusLabel.Text = "就绪";
            _statusLabel.Foreground = System.Windows.Media.Brushes.DodgerBlue;
            _volumeBar.Value = 0;
        });
    }

    private void Cleanup()
    {
        if (_trayIcon != null) { _trayIcon.Visible = false; _trayIcon.Dispose(); }
        _waveIn?.Dispose();
        _ws?.Dispose();
        LogToFile("===== 语音输入 Pro 退出 =====");
    }
}

动作配置 (JSON)

{
  "ActionId": "98a28a0f-d085-4bcd-ae64-6480f1300762",
  "Title": "语音输入",
  "Description": "通过 Quicker 鉴权调用讯飞语音识别 API,实时录音转文字并自动打字。支持动态修正和智能退格纠错。",
  "Icon": "https://files.getquicker.net/_icons/D49D91C62021C9686B330970F69CA0E348740B1A.png",
  "Variables": [
    {
      "Type": 0,
      "Desc": "识别结果文本",
      "IsOutput": true,
      "DefaultValue": "",
      "Key": "resultText"
    },
    {
      "Type": 0,
      "Desc": "是否自动打字(true/false)",
      "IsOutput": false,
      "DefaultValue": "true",
      "Key": "autoType"
    },
    {
      "Type": 0,
      "Desc": "返回状态",
      "IsOutput": true,
      "DefaultValue": "",
      "Key": "rtn"
    },
    {
      "Type": 0,
      "Desc": "错误信息",
      "IsOutput": true,
      "DefaultValue": "",
      "Key": "errMessage"
    },
    {
      "Type": 0,
      "Desc": "Quicker 鉴权 Token",
      "IsOutput": false,
      "DefaultValue": "",
      "Key": "quicker_token"
    },
    {
      "Type": 0,
      "Desc": "API 域名",
      "IsOutput": false,
      "DefaultValue": "iat-api.xfyun.cn",
      "Key": "api_host"
    }
  ],
  "References": [
    "System.Net.Http.dll",
    "System.Net.WebSockets.Client.dll",
    "WindowsBase.dll",
    "PresentationCore.dll",
    "PresentationFramework.dll",
    "System.Drawing.dll",
    "System.Windows.Forms.dll"
  ]
}

使用方法

  1. 确保您已安装 动作构建 (Action Builder) 动作。
  2. 将本文提供的 .cs.json 文件下载到同一目录
  3. 选中 .json 文件,运行「动作构建」即可生成并安装动作。
posted @ 2025-12-31 15:47  luoluoluo22  阅读(11)  评论(0)    收藏  举报