{"id":13602322,"url":"https://github.com/webfansplz/volute","last_synced_at":"2025-04-03T03:09:56.437Z","repository":{"id":60255770,"uuid":"307142435","full_name":"webfansplz/volute","owner":"webfansplz","description":"Raspberry Pi + Nodejs = Speech Robot","archived":false,"fork":false,"pushed_at":"2022-03-23T08:36:55.000Z","size":626,"stargazers_count":276,"open_issues_count":1,"forks_count":30,"subscribers_count":14,"default_branch":"master","last_synced_at":"2025-03-24T08:39:22.320Z","etag":null,"topics":["raspberry-pi","snowboy","speech"],"latest_commit_sha":null,"homepage":"","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/webfansplz.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2020-10-25T16:33:14.000Z","updated_at":"2025-02-26T10:24:55.000Z","dependencies_parsed_at":"2022-09-27T08:50:23.468Z","dependency_job_id":null,"html_url":"https://github.com/webfansplz/volute","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/webfansplz%2Fvolute","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/webfansplz%2Fvolute/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/webfansplz%2Fvolute/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/webfansplz%2Fvolute/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/webfansplz","download_url":"https://codeload.github.com/webfansplz/volute/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":246927835,"owners_count":20856198,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["raspberry-pi","snowboy","speech"],"created_at":"2024-08-01T18:01:20.097Z","updated_at":"2025-04-03T03:09:56.417Z","avatar_url":"https://github.com/webfansplz.png","language":"JavaScript","funding_links":[],"categories":["JavaScript"],"sub_categories":[],"readme":"## volute 是什么?\n\n\u003e volute(蜗壳)是一个使用 Raspberry Pi+Node.js 制作的语音助手.\n\n## 什么是树莓派?\n\n![raspberry-pi](https://s1.ax1x.com/2020/10/22/BiTO76.png)\n\n![raspberry-pi-4](https://s1.ax1x.com/2020/10/22/Bi7lBq.png)\n\n树莓派（英语：Raspberry Pi）是基于 Linux 的单片机电脑，由英国树莓派基金会开发，目的是以低价硬件及自由软件促进学校的基本计算机科学教育。\n\n树莓派每一代均使用博通（Broadcom）出产的 ARM 架构处理器，如今生产的机型内存在 2GB 和 8GB 之间，主要使用 SD 卡或者 TF 卡作为存储媒体，配备 USB 接口、HDMI 的视频输出（支持声音输出）和 RCA 端子输出，内置 Ethernet/WLAN/Bluetooth 网络链接的方式（依据型号决定），并且可使用多种操作系统。产品线型号分为 A 型、B 型、Zero 型和 ComputeModule 计算卡。\n\n\u003e 简单的说,这是一台可以放到口袋里的电脑!!\n\n## 什么是 Node.js?\n\n![node-js](https://s1.ax1x.com/2020/10/22/Bi7T58.jpg)\n\n\u003e Node.js 是一个能执行 Javascript 的环境,一个事件驱动 I/O 的 Javascript 环境,基于 Google 的 V8 引擎.\n\n## 什么是人机对话系统 ?\n\n![chatbot](https://s1.ax1x.com/2020/10/22/Biqiu9.png)\n\n\u003e 人机对话（Human-Machine Conversation）是指让机器理解和运用自然语言实现人机通信的技术。\n\n对话系统大致可分为 5 个基本模块：语音识别（ASR）、自然语音理解（NLU）、对话管理（DM）、自然语言生成（NLG）、语音合成（TTS）。\n\n- 语音识别（ASR）:完成语音到文本的转换，将用户说话的声音转化为语音。\n- 自然语言理解（NLU）:完成对文本的语义解析，提取关键信息，进行意图识别与实体识别。\n- 对话管理（DM）:负责对话状态维护、数据库查询、上下文管理等。\n- 自然语言生成（NLG）:生成相应的自然语言文本。\n- 语音合成（TTS）:将生成的文本转换为语音。\n\n## 材料准备\n\n- 树莓派 4B 主板\n- 树莓派 5V3A TYPE C 接口\n- 微型 USB 麦克风\n- 迷你音箱\n- 16G TF 卡\n- 川宇读卡器\n- 杜邦线,外壳,散热片...\n\n![material](https://s1.ax1x.com/2020/10/22/BiDGbn.jpg)\n\n## 树莓派系统安装及基础配置\n\n新的树莓派不像你买的 Macbook Pro 一样开机就能用 🐶,想要顺利体验树莓派,还得一步一步来~\n\n### 烧录操作系统\n\n树莓派没有硬盘结构,仅有一个 micro SD 卡插槽用于存储,因此要把操作系统装到 micro SD 卡中。\n\n树莓派支持许多操作系统,这里选择的是官方推荐的 Raspbian，这是一款基于 Debian Linux 的树莓派专用系统，适用于树莓派所有的型号。\n\n安装系统我用的是 Raspberry Pi Imager 工具为树莓派烧录系统镜像。\n\n![imager](https://s1.ax1x.com/2020/10/22/BF0X5V.png)\n\n### 基础配置\n\n要对树莓派进行配置,首先要启动系统(我们安装的是系统镜像,可免安装直接进入),然后将树莓派连接显示器即可看到系统桌面,我这里使用的是另一种方法:\n\n- 使用 IP Scanner 工具 扫描出 Raspberry Pi 的 IP\n\n![ip-scanner](https://s1.ax1x.com/2020/10/22/BkEXT0.png)\n\n- 扫描出 IP 后使用 VNC Viewer 工具 连接进系统\n\n![vnc-viewer](https://s1.ax1x.com/2020/10/22/BkEcyd.png)\n\n- 也可以直接 ssh 连接,然后通过 raspi-config 命令进行配置\n\n![ssh-pi](https://s1.ax1x.com/2020/10/22/BkV6hT.png)\n\n- 配置网络/分辨率/语言/输入输出音频等参数\n\n![asound](https://s1.ax1x.com/2020/10/22/BkeLmd.png)\n\n## volute 实现思路\n\n![volute](https://s1.ax1x.com/2020/10/22/BiDrr9.png)\n\n### 任务调度服务\n\n```js\nconst fs = require(\"fs\");\nconst path = require(\"path\");\nconst Speaker = require(\"speaker\");\nconst { record } = require(\"node-record-lpcm16\");\nconst XunFeiIAT = require(\"./services/xunfeiiat.service\");\nconst XunFeiTTS = require(\"./services/xunfeitts.service\");\nconst initSnowboy = require(\"./services/snowboy.service\");\nconst TulingBotService = require(\"./services/tulingbot.service\");\n// 任务调度服务\nconst taskScheduling = {\n  // 麦克风\n  mic: null,\n  speaker: null,\n  detector: null,\n  // 音频输入流\n  inputStream: null,\n  // 音頻輸出流\n  outputStream: null,\n  init() {\n    // 初始化snowboy\n    this.detector = initSnowboy({\n      record: this.recordSound.bind(this),\n      stopRecord: this.stopRecord.bind(this),\n    });\n    // 管道流,将麦克风接收到的流传递给snowboy\n    this.mic.pipe(this.detector);\n  },\n  start() {\n    // 监听麦克风输入流\n    this.mic = record({\n      sampleRate: 16000, // 采样率\n      threshold: 0.5,\n      verbose: true,\n      recordProgram: \"arecord\",\n    }).stream();\n    this.init();\n  },\n  // 记录音频输入\n  recordSound() {\n    // 每次记录前,先停止上次未播放完成的输出流\n    this.stopSpeak();\n    console.log(\"start record\");\n    // 创建可写流\n    this.inputStream = fs.createWriteStream(\n      path.resolve(__dirname, \"./assets/input.wav\"),\n      {\n        encoding: \"binary\",\n      }\n    );\n    // 管道流,将麦克风接受到的输入流 传递给 创建的可写流\n    this.mic.pipe(this.inputStream);\n  },\n  // 停止音频输入\n  stopRecord() {\n    if (this.inputStream) {\n      console.log(\"stop record\");\n      // 解绑this.mac绑定的管道流\n      this.mic.unpipe(this.inputStream);\n      this.mic.unpipe(this.detector);\n      process.nextTick(() =\u003e {\n        // 销毁输入流\n        this.inputStream.destroy();\n        this.inputStream = null;\n        // 重新初始化\n        this.init();\n        // 调用语音听写服务\n        this.speech2Text();\n      });\n    }\n  },\n  // speech to text\n  speech2Text() {\n    // 实例化 语音听写服务\n    const iatService = new XunFeiIAT({\n      onReply: (msg) =\u003e {\n        console.log(\"msg\", msg);\n        // 回调,调用聊天功能\n        this.onChat(msg);\n      },\n    });\n    iatService.init();\n  },\n  // 聊天-\u003e图灵机器人\n  onChat(text) {\n    // 实例化聊天机器人\n    TulingBotService.start(text).then((res) =\u003e {\n      console.log(res);\n      // 接收到聊天消息,调用语音合成服务\n      this.text2Speech(res);\n    });\n  },\n  // text to speech\n  text2Speech(text) {\n    // 实例化 语音合成服务\n    const ttsService = new XunFeiTTS({\n      text,\n      onDone: () =\u003e {\n        console.log(\"onDone\");\n        this.onSpeak();\n      },\n    });\n    ttsService.init();\n  },\n  // 播放,音频输出\n  onSpeak() {\n    // 实例化speaker,用于播放语音\n    this.speaker = new Speaker({\n      channels: 1,\n      bitDepth: 16,\n      sampleRate: 16000,\n    });\n    // 创建可读流\n    this.outputStream = fs.createReadStream(\n      path.resolve(__dirname, \"./assets/output.pcm\")\n    );\n    // this is just to activate the speaker, 2s delay\n    this.speaker.write(Buffer.alloc(32000, 10));\n    // 管道流,将输出流传递给speaker进行播放\n    this.outputStream.pipe(this.speaker);\n    this.outputStream.on(\"end\", () =\u003e {\n      this.outputStream = null;\n      this.speaker = null;\n    });\n  },\n  // 停止播放\n  stopSpeak() {\n    this.outputStream \u0026\u0026 this.outputStream.unpipe(this.speaker);\n  },\n};\ntaskScheduling.start();\n```\n\n### 热词唤醒 Snowboy\n\n语音助手需要像市面上的设备一样，需要唤醒。 如果没有唤醒步骤，一直做监听的话，对存储资源和网络连接的需求是非常大的。\n\nSnowboy 是一款高度可定制的唤醒词检测引擎(Hotwords Detection Library)，可以用于实时嵌入式系统，通过训练热词之后，可以离线运行，并且 功耗很低。当前，它可以运行在 Raspberry Pi、（Ubuntu）Linux 和 Mac OS X 系统上。\n\n![snowboy](https://s1.ax1x.com/2020/10/22/BirEzF.jpg)\n\n```js\nconst path = require(\"path\");\nconst snowboy = require(\"snowboy\");\nconst models = new snowboy.Models();\n\n// 添加训练模型\nmodels.add({\n  file: path.resolve(__dirname, \"../configs/volute.pmdl\"),\n  sensitivity: \"0.5\",\n  hotwords: \"volute\",\n});\n\n// 初始化 Detector 对象\nconst detector = new snowboy.Detector({\n  resource: path.resolve(__dirname, \"../configs/common.res\"),\n  models: models,\n  audioGain: 1.0,\n  applyFrontend: false,\n});\n\n/**\n * 初始化 initSnowboy\n * 实现思路:\n * 1. 监听到热词,进行唤醒,开始录音\n * 2. 录音期间,有声音时,重置silenceCount参数\n * 3. 录音期间,未接受到声音时,对silenceCount进行累加,当累加值大于3时,停止录音\n */\nfunction initSnowboy({ record, stopRecord }) {\n  const MAX_SILENCE_COUNT = 3;\n  let silenceCount = 0,\n    speaking = false;\n  /**\n   * silence事件回调,没声音时触发\n   */\n  const onSilence = () =\u003e {\n    console.log(\"silence\");\n    if (speaking \u0026\u0026 ++silenceCount \u003e MAX_SILENCE_COUNT) {\n      speaking = false;\n      stopRecord \u0026\u0026 stopRecord();\n      detector.off(\"silence\", onSilence);\n      detector.off(\"sound\", onSound);\n      detector.off(\"hotword\", onHotword);\n    }\n  };\n  /**\n   * sound事件回调,有声音时触发\n   */\n  const onSound = () =\u003e {\n    console.log(\"sound\");\n    if (speaking) {\n      silenceCount = 0;\n    }\n  };\n  /**\n   * hotword事件回调,监听到热词时触发\n   */\n  const onHotword = (index, hotword, buffer) =\u003e {\n    if (!speaking) {\n      silenceCount = 0;\n      speaking = true;\n      record \u0026\u0026 record();\n    }\n  };\n  detector.on(\"silence\", onSilence);\n  detector.on(\"sound\", onSound);\n  detector.on(\"hotword\", onHotword);\n  return detector;\n}\n\nmodule.exports = initSnowboy;\n```\n\n### 语音听写 科大讯飞 API\n\n语音转文字使用的是讯飞开放平台的语音听写服务.它可以将短音频（≤60 秒）精准识别成文字，除中文普通话和英文外，支持 25 种方言和 12 个语种，实时返回结果，达到边说边返回的效果。\n\n```js\nrequire(\"dotenv\").config();\nconst fs = require(\"fs\");\nconst WebSocket = require(\"ws\");\nconst { resolve } = require(\"path\");\nconst { createAuthParams } = require(\"../utils/auth\");\n\nclass XunFeiIAT {\n  constructor({ onReply }) {\n    super();\n    // websocket 连接\n    this.ws = null;\n    // 返回结果,解析后的消息文字\n    this.message = \"\";\n    this.onReply = onReply;\n    // 需要进行转换的输入流 语音文件\n    this.inputFile = resolve(__dirname, \"../assets/input.wav\");\n    // 接口 入参\n    this.params = {\n      host: \"iat-api.xfyun.cn\",\n      path: \"/v2/iat\",\n      apiKey: process.env.XUNFEI_API_KEY,\n      secret: process.env.XUNFEI_SECRET,\n    };\n  }\n  // 生成websocket连接\n  generateWsUrl() {\n    const { host, path } = this.params;\n    // 接口鉴权,参数加密\n    const params = createAuthParams(this.params);\n    return `ws://${host}${path}?${params}`;\n  }\n  // 初始化\n  init() {\n    const reqUrl = this.generateWsUrl();\n    this.ws = new WebSocket(reqUrl);\n    this.initWsEvent();\n  }\n  // 初始化websocket事件\n  initWsEvent() {\n    this.ws.on(\"open\", this.onOpen.bind(this));\n    this.ws.on(\"error\", this.onError);\n    this.ws.on(\"close\", this.onClose);\n    this.ws.on(\"message\", this.onMessage.bind(this));\n  }\n  /**\n   *  websocket open事件,触发表示已成功建立连接\n   */\n  onOpen() {\n    console.log(\"open\");\n    this.onPush(this.inputFile);\n  }\n  onPush(file) {\n    this.pushAudioFile(file);\n  }\n  // websocket 消息接收 回调\n  onMessage(data) {\n    const payload = JSON.parse(data);\n    if (payload.data \u0026\u0026 payload.data.result) {\n      // 拼接消息结果\n      this.message += payload.data.result.ws.reduce(\n        (acc, item) =\u003e acc + item.cw.map((cw) =\u003e cw.w),\n        \"\"\n      );\n      // status 2表示结束\n      if (payload.data.status === 2) {\n        this.onReply(this.message);\n      }\n    }\n  }\n  // websocket 关闭事件\n  onClose() {\n    console.log(\"close\");\n  }\n  // websocket 错误事件\n  onError(error) {\n    console.log(error);\n  }\n  /**\n   * 解析语音文件,将语音以二进制流的形式传送给后端\n   */\n  pushAudioFile(audioFile) {\n    this.message = \"\";\n    // 发送需要的载体参数\n    const audioPayload = (statusCode, audioBase64) =\u003e ({\n      common:\n        statusCode === 0\n          ? {\n              app_id: \"5f6cab72\",\n            }\n          : undefined,\n      business:\n        statusCode === 0\n          ? {\n              language: \"zh_cn\",\n              domain: \"iat\",\n              ptt: 0,\n            }\n          : undefined,\n      data: {\n        status: statusCode,\n        format: \"audio/L16;rate=16000\",\n        encoding: \"raw\",\n        audio: audioBase64,\n      },\n    });\n    const chunkSize = 9000;\n    // 创建buffer,用于存储二进制数据\n    const buffer = Buffer.alloc(chunkSize);\n    // 打开语音文件\n    fs.open(audioFile, \"r\", (err, fd) =\u003e {\n      if (err) {\n        throw err;\n      }\n\n      let i = 0;\n      // 以二进制流的形式递归发送\n      function readNextChunk() {\n        fs.read(fd, buffer, 0, chunkSize, null, (errr, nread) =\u003e {\n          if (errr) {\n            throw errr;\n          }\n          // nread表示文件流已读完,发送传输结束标识(status=2)\n          if (nread === 0) {\n            this.ws.send(\n              JSON.stringify({\n                data: { status: 2 },\n              })\n            );\n\n            return fs.close(fd, (err) =\u003e {\n              if (err) {\n                throw err;\n              }\n            });\n          }\n\n          let data;\n          if (nread \u003c chunkSize) {\n            data = buffer.slice(0, nread);\n          } else {\n            data = buffer;\n          }\n\n          const audioBase64 = data.toString(\"base64\");\n          const payload = audioPayload(i \u003e= 1 ? 1 : 0, audioBase64);\n          this.ws.send(JSON.stringify(payload));\n          i++;\n          readNextChunk.call(this);\n        });\n      }\n\n      readNextChunk.call(this);\n    });\n  }\n}\n\nmodule.exports = XunFeiIAT;\n```\n\n### 聊天机器人 图灵机器人 API\n\n图灵机器人 API V2.0 是基于图灵机器人平台语义理解、深度学习等核心技术，为广大开发者和企业提供的在线服务和开发接口。\n\n目前 API 接口可调用聊天对话、语料库、技能三大模块的语料：\n\n聊天对话是指平台免费提供的近 10 亿条公有对话语料，满足用户对话娱乐需求；\n\n语料库是指用户在平台上传的私有语料，仅供个人查看使用，帮助用户最便捷的搭建专业领域次的语料。\n\n技能服务是指平台打包的 26 种实用服务技能。涵盖生活、出行、购物等多个领域，一站式满足用户需求。\n\n```js\nrequire(\"dotenv\").config();\nconst axios = require(\"axios\");\n\n// 太简单了..懒得解释 🐶\n\nconst TulingBotService = {\n  requestUrl: \"http://openapi.tuling123.com/openapi/api/v2\",\n  start(text) {\n    return new Promise((resolve) =\u003e {\n      axios\n        .post(this.requestUrl, {\n          reqType: 0,\n          perception: {\n            inputText: {\n              text,\n            },\n          },\n          userInfo: {\n            apiKey: process.env.TULING_BOT_API_KEY,\n            userId: process.env.TULING_BOT_USER_ID,\n          },\n        })\n        .then((res) =\u003e {\n          // console.log(JSON.stringify(res.data, null, 2));\n          resolve(res.data.results[0].values.text);\n        });\n    });\n  },\n};\n\nmodule.exports = TulingBotService;\n```\n\n### 语音合成 科大讯飞 API\n\n语音合成流式接口将文字信息转化为声音信息，同时提供了众多极具特色的发音人（音库）供您选择。\n\n该语音能力是通过 Websocket API 的方式给开发者提供一个通用的接口。Websocket API 具备流式传输能力，适用于需要流式数据传输的 AI 服务场景。相较于 SDK，API 具有轻量、跨语言的特点；相较于 HTTP API，Websocket API 协议有原生支持跨域的优势。\n\n```js\nrequire(\"dotenv\").config();\nconst fs = require(\"fs\");\nconst WebSocket = require(\"ws\");\nconst { resolve } = require(\"path\");\nconst { createAuthParams } = require(\"../utils/auth\");\n\nclass XunFeiTTS {\n  constructor({ text, onDone }) {\n    super();\n    this.ws = null;\n    // 要转换的文字\n    this.text = text;\n    this.onDone = onDone;\n    // 转换后的语音文件\n    this.outputFile = resolve(__dirname, \"../assets/output.pcm\");\n    // 接口入参\n    this.params = {\n      host: \"tts-api.xfyun.cn\",\n      path: \"/v2/tts\",\n      appid: process.env.XUNFEI_APP_ID,\n      apiKey: process.env.XUNFEI_API_KEY,\n      secret: process.env.XUNFEI_SECRET,\n    };\n  }\n  // 生成websocket连接\n  generateWsUrl() {\n    const { host, path } = this.params;\n    const params = createAuthParams(this.params);\n    return `ws://${host}${path}?${params}`;\n  }\n  // 初始化\n  init() {\n    const reqUrl = this.generateWsUrl();\n    console.log(reqUrl);\n    this.ws = new WebSocket(reqUrl);\n    this.initWsEvent();\n  }\n  // 初始化websocket事件\n  initWsEvent() {\n    this.ws.on(\"open\", this.onOpen.bind(this));\n    this.ws.on(\"error\", this.onError);\n    this.ws.on(\"close\", this.onClose);\n    this.ws.on(\"message\", this.onMessage.bind(this));\n  }\n  /**\n   *  websocket open事件,触发表示已成功建立连接\n   */\n  onOpen() {\n    console.log(\"open\");\n    this.onSend();\n    if (fs.existsSync(this.outputFile)) {\n      fs.unlinkSync(this.outputFile);\n    }\n  }\n  // 发送要转换的参数信息\n  onSend() {\n    const frame = {\n      // 填充common\n      common: {\n        app_id: this.params.appid,\n      },\n      // 填充business\n      business: {\n        aue: \"raw\",\n        auf: \"audio/L16;rate=16000\",\n        vcn: \"xiaoyan\",\n        tte: \"UTF8\",\n      },\n      // 填充data\n      data: {\n        text: Buffer.from(this.text).toString(\"base64\"),\n        status: 2,\n      },\n    };\n    this.ws.send(JSON.stringify(frame));\n  }\n  // 保存转换后的语音结果\n  onSave(data) {\n    fs.writeFileSync(this.outputFile, data, { flag: \"a\" });\n  }\n  // websocket 消息接收 回调\n  onMessage(data, err) {\n    if (err) return;\n    const res = JSON.parse(data);\n    if (res.code !== 0) {\n      this.ws.close();\n      return;\n    }\n    // 接收消息结果并进行保存\n    const audio = res.data.audio;\n    const audioBuf = Buffer.from(audio, \"base64\");\n    this.onSave(audioBuf);\n    if (res.code == 0 \u0026\u0026 res.data.status == 2) {\n      this.ws.close();\n      this.onDone();\n    }\n  }\n  onClose() {\n    console.log(\"close\");\n  }\n  onError(error) {\n    console.log(error);\n  }\n}\n\nmodule.exports = XunFeiTTS;\n```\n\n## 效果演示\n\n[语雀-文章最底部可看效果](https://www.yuque.com/docs/share/df7fbb6d-d1ae-45cf-a7db-a37a38bd1e23?#%20%E3%80%8Avolute%E3%80%8B)\n\n## 源码地址\n\n[Github 源码地址](https://github.com/webfansplz/volute)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fwebfansplz%2Fvolute","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fwebfansplz%2Fvolute","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fwebfansplz%2Fvolute/lists"}