Loading [MathJax]/jax/output/CommonHTML/config.js
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
社区首页 >问答首页 >Microsoft Cognitive Services语音保存选项

Microsoft Cognitive Services语音保存选项
EN

Stack Overflow用户
提问于 2021-01-20 17:02:37
回答 1查看 221关注 0票数 0

我已经编写了以下代码,它可以将文本转换为语音并通过扬声器播放。我正在使用Microsoft Cognitive Services。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
<html lang="en">
<head>
  <title>Microsoft Cognitive Services Speech SDK JavaScript Sample for Speech Synthesis</title>
  <meta charset="utf-8" />
  <style>
    body {
      font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Roboto', 'Helvetica Neue', sans-serif;
      font-size: 14px;
    }

    table, th, td {
      border: 1px solid #f1f1f1;
      border-collapse: collapse;
    }

    th, td {
      padding: 10px;
    }

    textarea {
      font-family: Arial,sans-serif;
    }

    .mode {
      font-size: 18px;
    }

    .highlight{
      background-color: yellow;
    }

    input:not(disabled) {
      font-weight: bold;
      color: black;
    }

    button {
      padding: 4px 8px;
      background: #0078d4;
      color: #ffffff;
    }

    button:disabled {
      padding: 4px 8px;
      background: #ccc;
      color: #666;
    }

    input[type=radio] {
      position: relative;
      z-index: 1;
    }

    input[type=radio] + label {
      padding: 8px 4px 8px 30px;
      margin-left: -30px;
    }

    input[type=radio]:checked + label {
      background: #0078d4;
      color: #ffffff;
    }
  </style>
</head>
<body>
  <div id="warning">
    <h1 style="font-weight:500;">Speech Speech SDK not found
      (microsoft.cognitiveservices.speech.sdk.bundle.js missing).</h1>
  </div>
  
  <div id="content" style="display:none">
    <table>
      <tr>
        <td></td>
        <td><h1 style="font-weight:500;">Microsoft Cognitive Services</h1></td>
      </tr>
      <tr>
        <td align="right">
          <label for="subscriptionKey">
            <a href="https://docs.microsoft.com/azure/cognitive-services/speech-service/get-started"
               rel="noreferrer noopener"
               target="_blank">Subscription Key</a>
          </label>
        </td>
        <td><input id="subscriptionKey" type="text" size="40" placeholder="YourSubscriptionKey"></td>
      </tr>
      <tr>
        <td align="right"><label for="regionOptions">Region</label></td>
        <td>
<!--          see https://aka.ms/csspeech/region for more details-->
          <select id="regionOptions">
            <option value="uksouth">UK South</option>
          </select>
        </td>
      </tr>
      <tr>
        <td align="right"><label for="voiceOptions">Voice</label></td>
        <td>
          <button id="updateVoiceListButton">Update Voice List</button>
          <select id="voiceOptions" disabled>
            <option>Please update voice list first.</option>
          </select>
        </td>
      </tr>
      <tr>
        <td align="right"><label for="isSSML">Is SSML</label><br></td>
        <td>
          <input type="checkbox" id="isSSML" name="isSSML" value="ssml">
        </td>
      </tr>
      <tr>
        <td align="right"><label for="synthesisText">Text</label></td>
        <td>
          <textarea id="synthesisText" style="display: inline-block;width:500px;height:100px"
                 placeholder="Input text or ssml for synthesis."></textarea>
        </td>
      </tr>
      <tr>
        <td></td>
        <td>
          <button id="startSynthesisAsyncButton">Start synthesis</button>
          <button id="pauseButton">Pause</button>
          <button id="resumeButton">Resume</button>
        </td>
      </tr>
      <tr>
        <td align="right" valign="top"><label for="resultsDiv">Results</label></td>
        <td><textarea id="resultsDiv" readonly style="display: inline-block;width:500px;height:50px"></textarea></td>
      </tr>
      <tr>
        <td align="right" valign="top"><label for="eventsDiv">Events</label></td>
        <td><textarea id="eventsDiv" readonly style="display: inline-block;width:500px;height:200px"></textarea></td>
      </tr>
      <tr>
        <td align="right" valign="top"><label for="highlightDiv">Highlight</label></td>
        <td><div id="highlightDiv" style="display: inline-block;width:800px;"></div></td>
      </tr>
    </table>
  </div>

  <!-- Speech SDK reference sdk. -->
  <script src="microsoft.cognitiveservices.speech.sdk.bundle.js"></script>

  <!-- Speech SDK Authorization token -->
  <script>
  // Note: Replace the URL with a valid endpoint to retrieve
  //       authorization tokens for your subscription.
  var authorizationEndpoint = "token.php";

  function RequestAuthorizationToken() {
    if (authorizationEndpoint) {
      var a = new XMLHttpRequest();
      a.open("GET", authorizationEndpoint);
      a.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
      a.send("");
      a.onload = function() {
          var token = JSON.parse(atob(this.responseText.split(".")[1]));
          serviceRegion.value = token.region;
          authorizationToken = this.responseText;
          subscriptionKey.disabled = true;
          subscriptionKey.value = "using authorization token (hit F5 to refresh)";
          console.log("Got an authorization token: " + token);
      }
    }
  }
  </script>

  <!-- Speech SDK USAGE -->
  <script>
    // On document load resolve the Speech SDK dependency
    function Initialize(onComplete) {
      if (!!window.SpeechSDK) {
        document.getElementById('content').style.display = 'block';
        document.getElementById('warning').style.display = 'none';
        onComplete(window.SpeechSDK);
      }
    }
  </script>

  <!-- Browser Hooks -->
  <script>
    // status fields and start button in UI
    var resultsDiv, eventsDiv;
    var highlightDiv;
    var startSynthesisAsyncButton, pauseButton, resumeButton;
    var updateVoiceListButton;

    // subscription key and region for speech services.
    var subscriptionKey, regionOptions;
    var authorizationToken;
    var voiceOptions, isSsml;
    var SpeechSDK;
    var synthesisText;
    var synthesizer;
    var player;
    var wordBoundaryList = [];

    document.addEventListener("DOMContentLoaded", function () {
      startSynthesisAsyncButton = document.getElementById("startSynthesisAsyncButton");
      updateVoiceListButton = document.getElementById("updateVoiceListButton");
      pauseButton = document.getElementById("pauseButton");
      resumeButton = document.getElementById("resumeButton");
      subscriptionKey = document.getElementById("subscriptionKey");
      regionOptions = document.getElementById("regionOptions");
      resultsDiv = document.getElementById("resultsDiv");
      eventsDiv = document.getElementById("eventsDiv");
      voiceOptions = document.getElementById("voiceOptions");
      isSsml = document.getElementById("isSSML");
      highlightDiv = document.getElementById("highlightDiv");

      setInterval(function () {
        if (player !== undefined) {
          const currentTime = player.currentTime;
          var wordBoundary;
          for (const e of wordBoundaryList) {
            if (currentTime * 1000 > e.audioOffset / 10000) {
              wordBoundary = e;
            } else {
              break;
            }
          }
          if (wordBoundary !== undefined) {
            highlightDiv.innerHTML = synthesisText.value.substr(0, wordBoundary.textOffset) +
                    "<span class='highlight'>" + wordBoundary.text + "</span>" +
                    synthesisText.value.substr(wordBoundary.textOffset + wordBoundary.wordLength);
          } else {
            highlightDiv.innerHTML = synthesisText.value;
          }
        }
      }, 50);

      updateVoiceListButton.addEventListener("click", function () {
        var request = new XMLHttpRequest();
        request.open('GET',
                'https://' + regionOptions.value + ".tts.speech." +
                (regionOptions.value.startsWith("china") ? "azure.cn" : "microsoft.com") +
                        "/cognitiveservices/voices/list", true);
        if (authorizationToken) {
          request.setRequestHeader("Authorization", "Bearer " + authorizationToken);
        } else {
          if (subscriptionKey.value === "" || subscriptionKey.value === "subscription") {
            alert("Please enter your Microsoft Cognitive Services Speech subscription key!");
            return;
          }
          request.setRequestHeader("Ocp-Apim-Subscription-Key", subscriptionKey.value);
        }

        request.onload = function() {
          if (request.status >= 200 && request.status < 400) {
            const response = this.response;
            const neuralSupport = (response.indexOf("JessaNeural") > 0);
            const defaultVoice = neuralSupport ? "JessaNeural" : "JessaRUS";
            let selectId;
            const data = JSON.parse(response);
            voiceOptions.innerHTML = "";
            data.forEach((voice, index) => {
              voiceOptions.innerHTML += "<option value=\"" + voice.Name + "\">" + voice.Name + "</option>";
              if (voice.Name.indexOf(defaultVoice) > 0) {
                selectId = index;
              }
            });
            voiceOptions.selectedIndex = selectId;
            voiceOptions.disabled = false;
          } else {
            window.console.log(this);
            eventsDiv.innerHTML += "cannot get voice list, code: " + this.status + " detail: " + this.statusText + "\r\n";
          }
        };

        request.send()
      });

      pauseButton.addEventListener("click", function () {
        player.pause();
        pauseButton.disabled = true;
        resumeButton.disabled = false;
      });

      resumeButton.addEventListener("click", function () {
        player.resume();
        pauseButton.disabled = false;
        resumeButton.disabled = true;
      });

      startSynthesisAsyncButton.addEventListener("click", function () {
        startSynthesisAsyncButton.disabled = true;
        resultsDiv.innerHTML = "";
        eventsDiv.innerHTML = "";
        wordBoundaryList = [];
        synthesisText = document.getElementById("synthesisText");

        // if we got an authorization token, use the token. Otherwise use the provided subscription key
        var speechConfig;
        if (authorizationToken) {
          speechConfig = SpeechSDK.SpeechConfig.fromAuthorizationToken(authorizationToken, serviceRegion.value);
        } else {
          if (subscriptionKey.value === "" || subscriptionKey.value === "subscription") {
            alert("Please enter your Microsoft Cognitive Services Speech subscription key!");
            return;
          }
          speechConfig = SpeechSDK.SpeechConfig.fromSubscription(subscriptionKey.value, regionOptions.value);
        }

        speechConfig.speechSynthesisVoiceName = voiceOptions.value;
        // The SDK uses Media Source Extensions (https://www.w3.org/TR/media-source/) for playback.
        // Mp3 format is supported in most browsers.
        speechConfig.speechSynthesisOutputFormat = SpeechSDK.SpeechSynthesisOutputFormat.Audio16Khz32KBitRateMonoMp3;
        player = new SpeechSDK.SpeakerAudioDestination();
        player.onAudioEnd = function (_) {
          window.console.log("playback finished");
          eventsDiv.innerHTML += "playback finished" + "\r\n";
          startSynthesisAsyncButton.disabled = false;
          pauseButton.disabled = true;
          resumeButton.disabled = true;
          wordBoundaryList = [];
        };

        var audioConfig  = SpeechSDK.AudioConfig.fromSpeakerOutput(player);
        var audioConfig = AudioConfig.FromWavFileOutput("/var/www/html/unitypst/customfiles/tts/ttsfile.wav");
        
        synthesizer = new SpeechSDK.SpeechSynthesizer(speechConfig, audioConfig);

        // The event synthesizing signals that a synthesized audio chunk is received.
        // You will receive one or more synthesizing events as a speech phrase is synthesized.
        // You can use this callback to streaming receive the synthesized audio.
        synthesizer.synthesizing = function (s, e) {
          window.console.log(e);
          eventsDiv.innerHTML += "(synthesizing) Reason: " + SpeechSDK.ResultReason[e.result.reason] +
                  "Audio chunk length: " + e.result.audioData.byteLength + "\r\n";
        };

        // The synthesis started event signals that the synthesis is started.
        synthesizer.synthesisStarted = function (s, e) {
          window.console.log(e);
          eventsDiv.innerHTML += "(synthesis started)" + "\r\n";
          pauseButton.disabled = false;
        };

        // The event synthesis completed signals that the synthesis is completed.
        synthesizer.synthesisCompleted = function (s, e) {
          console.log(e);
          eventsDiv.innerHTML += "(synthesized)  Reason: " + SpeechSDK.ResultReason[e.result.reason] +
                  " Audio length: " + e.result.audioData.byteLength + "\r\n";
        };

        // The event signals that the service has stopped processing speech.
        // This can happen when an error is encountered.
        synthesizer.SynthesisCanceled = function (s, e) {
          const cancellationDetails = SpeechSDK.CancellationDetails.fromResult(e.result);
          let str = "(cancel) Reason: " + SpeechSDK.CancellationReason[cancellationDetails.reason];
          if (cancellationDetails.reason === SpeechSDK.CancellationReason.Error) {
            str += ": " + e.result.errorDetails;
          }
          window.console.log(e);
          eventsDiv.innerHTML += str + "\r\n";
          startSynthesisAsyncButton.disabled = false;
          pauseButton.disabled = true;
          resumeButton.disabled = true;
        };

        // This event signals that word boundary is received. This indicates the audio boundary of each word.
        // The unit of e.audioOffset is tick (1 tick = 100 nanoseconds), divide by 10,000 to convert to milliseconds.
        synthesizer.wordBoundary = function (s, e) {
          window.console.log(e);
          eventsDiv.innerHTML += "(WordBoundary), Text: " + e.text + ", Audio offset: " + e.audioOffset / 10000 + "ms." + "\r\n";
          wordBoundaryList.push(e);
        };

        const complete_cb = function (result) {
          if (result.reason === SpeechSDK.ResultReason.SynthesizingAudioCompleted) {
            resultsDiv.innerHTML += "synthesis finished";
          } else if (result.reason === SpeechSDK.ResultReason.Canceled) {
            resultsDiv.innerHTML += "synthesis failed. Error detail: " + result.errorDetails;
          }
          window.console.log(result);
          synthesizer.close();
          synthesizer = undefined;
        };
        const err_cb = function (err) {
          startSynthesisAsyncButton.disabled = false;
          phraseDiv.innerHTML += err;
          window.console.log(err);
          synthesizer.close();
          synthesizer = undefined;
        };
        if (isSsml.checked) {
          synthesizer.speakSsmlAsync(synthesisText.value,
                  complete_cb,
                  err_cb);
        } else {
          synthesizer.speakTextAsync(synthesisText.value,
                  complete_cb,
                  err_cb);
        }
      });

      Initialize(function (speechSdk) {
        SpeechSDK = speechSdk;
        startSynthesisAsyncButton.disabled = false;
        pauseButton.disabled = true;
        resumeButton.disabled = true;
        saveButton.disabled = true;

        // in case we have a function for getting an authorization token, call it.
        if (typeof RequestAuthorizationToken === "function") {
          RequestAuthorizationToken();
        }
      });
    });
  </script>
</body>
</html>

我现在要做的是添加选项,将合成的语音数据保存到磁盘,并提示用户下载文件。使用Microsoft Cognitive Services Speech SDK可以实现吗?我应该如何修改代码以支持“保存”或“下载”选项?

EN

回答 1

Stack Overflow用户

发布于 2021-01-26 12:06:48

我们有a sample来展示如何使用Azure Speech SDK在浏览器下保存TTS音频。

票数 0
EN
页面原文内容由Stack Overflow提供。腾讯云小微IT领域专用引擎提供翻译支持
原文链接:

https://stackoverflow.com/questions/65814124

复制
相关文章
Android6.0蓝牙开发中获取附近低功耗蓝牙设备结果权限问题分析
问题描述: fang_fang_story 近期做一个扫描附近低功耗蓝牙设备获取到rssi并进行一系列的相对的定位的功能。在开发前期一直使用低版本(Android6.0以下)的手机进行测试,没有任何问题。在运行到Android6.0的手机上后,出了一个问题。 每当扫描到附近ble设备并进行回调时都会报错,根本获取不了扫描的结果,报错如下: D/BluetoothLeScanner: onClientRegistered() - status=0 clientIf=5 W/Binder: Caught a
fanfan
2018/01/24
1.7K0
Andorid 对接BLE蓝牙设备(连接篇)
笔者前段时间做了一个功能,需要对接一个蓝牙设备,该蓝牙设备使用的就是BLE蓝牙。这里给大家分享一下我的实现。这篇文章主要是实现程序与BLE蓝牙设备的连接,交互和设置、测试工具等请期待下一篇文章。
饮水思源为名
2019/10/16
1.6K0
你的产品是如何估值的?
本文作者Steve Sloane,是Menlo Ventures的负责人。在本文中,他通过三个部分介绍了风投如何对企业进行估值的方法,下面我们就一一进行说明。 一、营收倍数溯源 随着一些股票自身股价的
人称T客
2018/06/06
9300
世界是由懒人改变的
最近把放下了近大半年的Hexo博客重新拾起来了,写篇文章记录一下。至于当初为什么会放下,其中一个原因是用户体验太差,还需要手动创建移动md文件,仿佛回到了原始社会。其实最重要的原因是自己太懒。最近重新拾起来Hexo博客的原因也挺简单,大脑是用来思考事物的而不是记录事物的,得有一个地方记录平时一些琐碎的想法,公众号和技术博客显然不是太合适。但是一想起Hexo那原始的操作,就有点头大。在网上搜了搜发现还真有一款管理插件Hexo Admin,能以网页的方式管理Hexo博客。看来这个世界还真是由懒人改变的。
撸码那些事
2018/12/12
5660
世界是由懒人改变的
js计算出来的文件md5值跟java计算出来的不一致
最近在项目中遇到了大文件分割上传问题,为了保证上传的文件的有效性需要确保分割的文件上传首先要成功,因此用到了md5加密,在js代码中上传文件之前将要上传的文件内容进行md5加密,然后作为其中一个参数传到后端服务器,后端再收到文件后对文件进行同样的md5加密,然后将两个md5值对比,验证成功则人为文件分割块是正确的,然后保存,但是却遇到一个问题:
johnhuster的分享
2022/03/28
3.7K0
蓝牙信号强度RSSI
Received Signal Strength Indication接收的信号强度指示,无线发送层的可选部分,用来判定链接质量,以及是否增大广播发送强度。RSSI(Received Signal Strength Indicator)是接收信号的强度指示,它的实现是在反向通道基带接收滤波器之后进行的。(摘自百度)。
黄林晴
2019/01/10
7.2K0
Android 12 蓝牙适配 Java版
  本身已经写过一篇关于蓝牙适配的文章了,不过因为是Kotlin,很多读者看不懂,对此我深感无奈,一开始也没有想过再写Java版本的,但是后面发现看不懂的越来越多了,我意识到不对劲了,因此我觉得再写一个Java版本的。
晨曦_LLW
2023/01/07
2.8K0
Android 12 蓝牙适配 Java版
gcc编译时,链接器安排的【虚拟地址】是如何计算出来的?
昨天下午,旁边的同事在学习Linux系统中的虚拟地址映射(经典书籍《程序员的自我修养-链接、装载与库》),在看到6.4章节的时候,对于一个可执行的ELF文件中,虚拟地址的值百思不得其解!
IOT物联网小镇
2022/04/06
1.3K0
gcc编译时,链接器安排的【虚拟地址】是如何计算出来的?
Android 12 蓝牙适配
  在我的申请下,公司终于购买了一台基于Android12.0的手机,然后我就开心的拿去安装测试了,发现程序崩溃了,于是我这里就写下来,Android12.0的蓝牙适配方法。
晨曦_LLW
2022/04/27
2.1K0
Android 12 蓝牙适配
Android 低功耗蓝牙开发简述
  低功耗蓝牙是在传统蓝牙的基础上开发的,但它与传统模块不同。最大的特点是降低了成本和功耗。可以快速搜索并快速连接。它保持连接并以超低功耗传输数据,低功耗蓝牙是专门针对基于物联网(IoT)设备构建的功能和应用程序设计的蓝牙版本。蓝牙BLE允许短期远程无线电连接并延长电池寿命。目前,蓝牙低功耗技术已被广泛使用,例如耳机、手环、电子秤、鼠标、键盘、灯、音箱等设备。
晨曦_LLW
2022/09/29
1.4K0
Android 低功耗蓝牙开发简述
Android连续的获取蓝牙的RSSI
基于蓝牙的RSSI可以有很多应用,要获得蓝牙的RSSI无外乎两种方法,一种就是基于扫瞄的方法,优点是Android本身支持,缺点是scan的时间比较长,并且中间过程不受控制,为了连续的测量,需要不断的scan;第二种就是,基于连接的方法,前提是要建立两个蓝牙设备的连接后,再测量RSSI,优点是后期测量比较方便,间隔时间也较短。
黄林晴
2022/01/20
1.6K0
Android连续的获取蓝牙的RSSI
MASA MAUI Plugin 安卓蓝牙低功耗(一)蓝牙扫描
MAUI的出现,赋予了广大Net开发者开发多平台应用的能力,MAUI 是Xamarin.Forms演变而来,但是相比Xamarin性能更好,可扩展性更强,结构更简单。但是MAUI对于平台相关的实现并不完整。
JusterZhu
2022/12/07
1.4K0
MASA MAUI Plugin 安卓蓝牙低功耗(一)蓝牙扫描
18.9% 的网站是由 WordPress 创建
WordPress 背后公司 Automattic 创始人 Matt Mullenweg 在上周六旧金山举行的 WordCamp 2013 会议上谈到了 WordPress 最新的发展情况,以及即将发布的 3.6 版本,和筹备当中的 3.7 和 3.8 版本的开发计划,并宣布推出开发者资源站,将 WordPress 转型成应用平台。
Denis
2023/04/15
4690
数据库是如何分片的?
如果你使用过 Google 或 YouTube,那么你很可能已经访问过分片数据。分片通过将数据分区存储在多个服务器上,而不是将所有内容放在一个巨大的服务器上,以实现扩展数据库的目的。这篇文章将介绍数据库分片的工作原理、思考如何给你自己的数据库分片,以及其他一些有用的、可以提供帮助的工具,尤其是针对 MySQL 和 Postgres。
出其东门
2023/09/02
3920
数据库是如何分片的?
response 值由三步计算而成
1、对用户名、认证域(realm)以及密码的合并值计算 MD5 哈希值,结果称为 HA1。 2、对HTTP方法以及URI的摘要的合并值计算 MD5 哈希值,例如,"GET" 和 "/dir/index.html",结果称为 HA2。 3、对HA1、服务器密码随机数(nonce)、请求计数(nc)、客户端密码随机数(cnonce)、保护质量(qop)以及 HA2 的合并值计算 MD5 哈希值。结果即为客户端提供的 response 值。
用户7718188
2021/10/08
5180
Android Ble蓝牙App(一)扫描
  关于低功耗的蓝牙介绍我已经做过很多了,只不过很多人不是奔着学习的目的去的,拿着源码就去运行,后面又发现连接设备后马上断开,然后不会自己看问题,这个现象就是快餐式的,你不了解里面的知识内容,自然就不知道是怎么回事,重复的问题我回答了好多次了。而我也是觉得写的有问题,本意上来说我是希望读者可以参考来写,能看一看文章内容,而结果绝大多数,看个标题看个运行效果,下载源码就运行,运行有问题就问你,没有什么思考。   针对这个情况,我决定做了系列性的Ble蓝牙App,尽可能的避免在你运行的时候出现bug,所以这是一个低功耗蓝牙工具App,可以让你了解到一些东西。注意是低功耗,不是经典蓝牙,如果你不知道两者之间的区别,建议你先了解一下。本文的效果:
晨曦_LLW
2023/08/02
1.2K0
Android Ble蓝牙App(一)扫描
38. 对焦扫描技术是如何实现EDOF(扩展景深)的?
我们之前讲解了37. 如何从失焦的图像中恢复景深并将图像变清晰?,我们看到了编码光圈的应用,它可以让我们很容易估计出局部卷积核,从而可以利用去卷积技术得到全焦图像,甚至还可以得到场景的相对深度图。同时,我们也看到了计算摄影学不仅仅是软件的事情,有的时候也会涉及到一些必要的硬件。
HawkWang
2020/04/17
1.7K0
38. 对焦扫描技术是如何实现EDOF(扩展景深)的?
点击加载更多

相似问题

bluetoothLeScanner扫描时间有多长?

134

从扫描请求/扫描应答包获取RSSI

13

android系统的连续RSSI扫描

13

由阿尔特信标库127返回的rssi扰乱了距离

13

BluetoothLeScanner开始扫描仅转到错误代码2

25
添加站长 进交流群

领取专属 10元无门槛券

AI混元助手 在线答疑

扫码加入开发者社群
关注 腾讯云开发者公众号

洞察 腾讯核心技术

剖析业界实践案例

扫码关注腾讯云开发者公众号
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
查看详情【社区公告】 技术创作特训营有奖征文