[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"
]
}
使用方法
- 确保您已安装 动作构建 (Action Builder) 动作。
- 将本文提供的
.cs和.json文件下载到同一目录。 - 选中
.json文件,运行「动作构建」即可生成并安装动作。
浙公网安备 33010602011771号