ruiyun-human 1.0.12 → 1.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -385,18 +385,97 @@ var recorder = { exports: {} };
385
385
  var recorderExports = recorder.exports;
386
386
  var jsAudioRecorder = recorderExports;
387
387
  const Recorder = /* @__PURE__ */ getDefaultExportFromCjs(jsAudioRecorder);
388
- const baseTTSUrl = "http://192.168.3.182:8002";
389
- const ASR_URL = "http://192.168.3.182:9988";
388
+ const VOICE_CLIENT_ID = "e5cd2b";
389
+ const VOICE_TENANT_ID = "000000";
390
+ const TTS_USER = "web-test-user";
391
+ const TTS_PCM_SAMPLE_RATE = 16e3;
392
+ const TTS_MAX_RETRIES = 5;
390
393
  const _sfc_main = {
391
394
  __name: "index",
392
395
  props: {
393
- humans: {
394
- type: Array,
395
- default: () => []
396
+ human: {
397
+ type: Object,
398
+ default: null
396
399
  }
397
400
  },
398
401
  emits: ["loaded", "changed", "paused", "resumed", "audio-ready"],
399
402
  setup(__props, { expose: __expose, emit: __emit }) {
403
+ const TTS_URL = "http://localhost:6039";
404
+ const ASR_URL = "http://localhost:6039";
405
+ const HUMAN_PRESETS = {
406
+ 1: {
407
+ humanId: 1,
408
+ title: "西装动漫男",
409
+ // TODO: 替换为真实资源
410
+ folder: "http://192.168.3.107:8088/img/xiangyu-green-suit",
411
+ folder_mobile: "http://192.168.3.107:8088/img/xiangyu-green-suit-header",
412
+ standby: { interval: 100, standbyNum: 11 },
413
+ speak: { interval: 30, speakNum: 0 },
414
+ humanType: 1,
415
+ timbre: 1
416
+ },
417
+ 2: {
418
+ humanId: 2,
419
+ title: "西装动漫女",
420
+ folder: "http://192.168.3.107:8088/img/anime-woman",
421
+ folder_mobile: "http://192.168.3.107:8088/img/anime-woman-header",
422
+ standby: { interval: 100, standbyNum: 1 },
423
+ speak: { interval: 30, speakNum: 75 },
424
+ humanType: 1,
425
+ timbre: 3
426
+ },
427
+ 3: {
428
+ humanId: 3,
429
+ title: "真人形象男",
430
+ folder: "http://192.168.3.107:8088/img/real-man",
431
+ folder_mobile: "http://192.168.3.107:8088/img/real-man-header",
432
+ standby: { interval: 100, standbyNum: 1 },
433
+ speak: { interval: 30, speakNum: 139 },
434
+ humanType: 1,
435
+ timbre: 47
436
+ },
437
+ 4: {
438
+ humanId: 4,
439
+ title: "真人形象女",
440
+ folder: "http://192.168.3.107:8088/img/real-woman",
441
+ folder_mobile: "http://192.168.3.107:8088/img/real-woman-header",
442
+ standby: { interval: 100, standbyNum: 2 },
443
+ speak: { interval: 40, speakNum: 139 },
444
+ humanType: 1,
445
+ timbre: 34
446
+ },
447
+ 5: {
448
+ humanId: 5,
449
+ title: "Q版形象",
450
+ folder: "http://192.168.3.107:8088/img/xiangyu-q",
451
+ folder_mobile: "http://192.168.3.107:8088/img/xiangyu-q-header",
452
+ standby: { interval: 100, standbyNum: 28 },
453
+ speak: { interval: 30, speakNum: 118 },
454
+ humanType: 1,
455
+ timbre: 34
456
+ }
457
+ };
458
+ const isMobile = () => {
459
+ if (typeof navigator === "undefined") return false;
460
+ return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|HarmonyOS/i.test(navigator.userAgent);
461
+ };
462
+ const resolveFolder = (human) => {
463
+ const finalType = human.humanType || (isMobile() ? 2 : 1);
464
+ if (finalType === 2 && human.folder_mobile) return human.folder_mobile;
465
+ return human.folder;
466
+ };
467
+ const resolveHuman = (input) => {
468
+ if (input == null) return null;
469
+ const humanId = typeof input === "number" ? input : input.humanId;
470
+ const preset = HUMAN_PRESETS[humanId];
471
+ if (!preset) {
472
+ console.error("[DigitalHuman] 未找到 humanId=", humanId, "的预设形象,可用 id:", Object.keys(HUMAN_PRESETS));
473
+ return null;
474
+ }
475
+ const merged = { ...preset, ...typeof input === "object" ? input : {} };
476
+ merged.folder = resolveFolder(merged);
477
+ return merged;
478
+ };
400
479
  const props = __props;
401
480
  const emit = __emit;
402
481
  const containerRef = vue.ref(null);
@@ -418,27 +497,38 @@ const _sfc_main = {
418
497
  standbyNum: currentHuman.value.standby.standbyNum,
419
498
  speakNum: currentHuman.value.speak.speakNum
420
499
  };
421
- for (let i = 1; i <= standbyNum; i++) {
500
+ const makeImage = (url) => {
422
501
  const image = new Image();
423
- image.src = encodeURI(`${folder}/standby/standby (${i}).png`);
424
- frames.value.standby.push(image);
502
+ image.src = encodeURI(url);
503
+ image.onload = () => {
504
+ image.__broken = false;
505
+ };
506
+ image.onerror = () => {
507
+ image.__broken = true;
508
+ console.warn("[DigitalHuman] 帧加载失败:", url);
509
+ };
510
+ return image;
511
+ };
512
+ for (let i = 1; i <= standbyNum; i++) {
513
+ frames.value.standby.push(makeImage(`${folder}/standby/standby (${i}).png`));
514
+ }
515
+ if (speakNum <= 0) {
516
+ console.warn("[DigitalHuman] 当前形象 speakNum=0,说话动画暂用待机帧替代");
517
+ frames.value.speakStart = [...frames.value.standby];
518
+ frames.value.speakLoop = [...frames.value.standby];
519
+ frames.value.speakEnd = [...frames.value.standby];
520
+ return;
425
521
  }
426
522
  const startEnd = Math.floor(speakNum * 0.6);
427
523
  const loopEnd = Math.floor(speakNum * 0.9);
428
524
  for (let i = 1; i <= startEnd; i++) {
429
- const image = new Image();
430
- image.src = encodeURI(`${folder}/speak/speak (${i}).png`);
431
- frames.value.speakStart.push(image);
525
+ frames.value.speakStart.push(makeImage(`${folder}/speak/speak (${i}).png`));
432
526
  }
433
527
  for (let i = startEnd; i <= loopEnd; i++) {
434
- const image = new Image();
435
- image.src = encodeURI(`${folder}/speak/speak (${i}).png`);
436
- frames.value.speakLoop.push(image);
528
+ frames.value.speakLoop.push(makeImage(`${folder}/speak/speak (${i}).png`));
437
529
  }
438
530
  for (let i = loopEnd; i <= speakNum; i++) {
439
- const image = new Image();
440
- image.src = encodeURI(`${folder}/speak/speak (${i}).png`);
441
- frames.value.speakEnd.push(image);
531
+ frames.value.speakEnd.push(makeImage(`${folder}/speak/speak (${i}).png`));
442
532
  }
443
533
  };
444
534
  const draw = (image) => {
@@ -599,20 +689,38 @@ const _sfc_main = {
599
689
  };
600
690
  allFrames.forEach((img) => {
601
691
  if (img.complete) {
692
+ if (!img.naturalWidth) img.__broken = true;
602
693
  checkLoaded();
603
694
  } else {
604
- img.onload = checkLoaded;
605
- img.onerror = checkLoaded;
695
+ img.onload = () => {
696
+ img.__broken = false;
697
+ checkLoaded();
698
+ };
699
+ img.onerror = () => {
700
+ img.__broken = true;
701
+ checkLoaded();
702
+ };
606
703
  }
607
704
  });
608
705
  };
609
706
  const init = () => {
610
707
  vue.nextTick(() => {
708
+ var _a, _b;
611
709
  if (!containerRef.value || !canvasRef.value) return;
612
- if (!props.humans || props.humans.length === 0) return;
613
- if (!currentHuman.value) {
614
- currentHuman.value = props.humans[0];
710
+ const rawInput = props.human;
711
+ if (!rawInput) return;
712
+ const human = resolveHuman(rawInput);
713
+ if (!human) return;
714
+ const prevType = (_a = currentHuman.value) == null ? void 0 : _a.humanType;
715
+ if (((_b = currentHuman.value) == null ? void 0 : _b.humanId) === human.humanId && prevType === human.humanType) {
716
+ Object.assign(currentHuman.value, human);
717
+ return;
615
718
  }
719
+ abortTTS();
720
+ stop();
721
+ timestamp.value = null;
722
+ frameIndex.value = 1;
723
+ currentHuman.value = human;
616
724
  const canvas = canvasRef.value;
617
725
  const dpr = window.devicePixelRatio || 1;
618
726
  const width = containerRef.value.clientWidth;
@@ -649,112 +757,94 @@ const _sfc_main = {
649
757
  currentAudio.value = null;
650
758
  }
651
759
  }
652
- const voiceFileCache = /* @__PURE__ */ new Map();
653
- const FILEDATA_META = { _type: "gradio.FileData" };
654
- const uploadReferenceAudio = async (blob, filename, signal) => {
655
- const form = new FormData();
656
- form.append("files", blob, filename);
657
- const res = await fetch(`${baseTTSUrl}/gradio_api/upload`, {
658
- method: "POST",
659
- body: form,
660
- signal
661
- });
662
- if (!res.ok) throw new Error(`参考音频上传失败 HTTP ${res.status}`);
663
- const paths = await res.json();
664
- const path = Array.isArray(paths) ? paths[0] : paths;
665
- if (!path) throw new Error("参考音频上传失败:服务未返回文件路径");
666
- return { path, orig_name: filename, meta: FILEDATA_META };
760
+ const base64ToBlob = (b64, mime = "audio/wav") => {
761
+ const raw = String(b64).replace(/^data:audio\/[\w+-]+;base64,/, "");
762
+ const binary = atob(raw);
763
+ const bytes = new Uint8Array(binary.length);
764
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
765
+ return new Blob([bytes], { type: mime });
667
766
  };
668
- const resolveVoiceFileData = async (voiceSource, signal) => {
669
- if (voiceSource && typeof voiceSource === "object" && !Array.isArray(voiceSource) && voiceSource.path) {
670
- return {
671
- path: voiceSource.path,
672
- orig_name: voiceSource.orig_name || "reference.mp3",
673
- meta: FILEDATA_META
674
- };
675
- }
676
- if (voiceSource instanceof Blob) {
677
- const name = voiceSource.name || `reference_${Date.now()}.mp3`;
678
- return await uploadReferenceAudio(voiceSource, name, signal);
679
- }
680
- if (typeof voiceSource === "string" && voiceSource) {
681
- if (voiceSource.startsWith(baseTTSUrl)) {
682
- const match = voiceSource.match(/\/gradio_api\/file=(.+)$/);
683
- if (match) {
684
- const path = decodeURIComponent(match[1]);
685
- const origName = path.split(/[\\/]/).pop() || "reference.mp3";
686
- return { path, orig_name: origName, meta: FILEDATA_META };
687
- }
688
- }
689
- const res = await fetch(voiceSource, { signal });
690
- if (!res.ok) throw new Error(`参考音频下载失败 HTTP ${res.status}`);
691
- const blob = await res.blob();
692
- const fileName = decodeURIComponent(voiceSource.split("?")[0].split("/").pop()) || "reference.mp3";
693
- return await uploadReferenceAudio(blob, fileName, signal);
694
- }
695
- throw new Error("不支持的参考音频来源:voiceUrl 应为音频 URL 字符串、File/Blob 或 { path } 对象");
767
+ const pcmToWavBlob = (pcmBuffer, sampleRate = TTS_PCM_SAMPLE_RATE) => {
768
+ const dataLength = pcmBuffer.byteLength;
769
+ const header = new ArrayBuffer(44);
770
+ const view = new DataView(header);
771
+ const writeStr = (offset, str) => {
772
+ for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
773
+ };
774
+ writeStr(0, "RIFF");
775
+ view.setUint32(4, 36 + dataLength, true);
776
+ writeStr(8, "WAVE");
777
+ writeStr(12, "fmt ");
778
+ view.setUint32(16, 16, true);
779
+ view.setUint16(20, 1, true);
780
+ view.setUint16(22, 1, true);
781
+ view.setUint32(24, sampleRate, true);
782
+ view.setUint32(28, sampleRate * 2, true);
783
+ view.setUint16(32, 2, true);
784
+ view.setUint16(34, 16, true);
785
+ writeStr(36, "data");
786
+ view.setUint32(40, dataLength, true);
787
+ return new Blob([header, pcmBuffer], { type: "audio/wav" });
696
788
  };
697
- const submitCloneJob = async (data, signal) => {
698
- const res = await fetch(`${baseTTSUrl}/gradio_api/call/_clone_fn`, {
699
- method: "POST",
700
- headers: { "Content-Type": "application/json" },
701
- body: JSON.stringify({ data }),
702
- signal
703
- });
704
- if (!res.ok) {
705
- const text = await res.text().catch(() => "");
706
- throw new Error(`克隆任务提交失败 HTTP ${res.status} ${text}`);
789
+ const ensurePlayableWavBlob = async (blob) => {
790
+ const buf = await blob.arrayBuffer();
791
+ if (buf.byteLength < 4) return blob;
792
+ const head = String.fromCharCode(...new Uint8Array(buf, 0, 4));
793
+ if (head === "RIFF") {
794
+ return new Blob([buf], { type: "audio/wav" });
707
795
  }
708
- const json = await res.json();
709
- if (!(json == null ? void 0 : json.event_id)) throw new Error("克隆任务提交失败:未返回 event_id");
710
- return json.event_id;
796
+ console.log("[TTS] 检测到裸 PCM,补 WAV 头后播放", { size: buf.byteLength });
797
+ return pcmToWavBlob(buf);
711
798
  };
712
- const waitCloneResult = async (eventId, signal) => {
713
- const res = await fetch(`${baseTTSUrl}/gradio_api/call/_clone_fn/${eventId}`, {
714
- signal,
715
- headers: { Accept: "text/event-stream" }
716
- });
717
- if (!res.ok) throw new Error(`合成结果监听失败 HTTP ${res.status}`);
718
- const reader = res.body.getReader();
719
- const decoder = new TextDecoder("utf-8");
720
- let buffer = "";
721
- let eventName = "message";
722
- while (true) {
723
- const { done, value } = await reader.read();
724
- if (done) break;
725
- buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
726
- let sep;
727
- while ((sep = buffer.indexOf("\n\n")) !== -1) {
728
- const frame = buffer.slice(0, sep);
729
- buffer = buffer.slice(sep + 2);
730
- const dataLines = [];
731
- for (const line of frame.split("\n")) {
732
- if (line.startsWith("event:")) eventName = line.slice(6).trim();
733
- else if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
734
- }
735
- if (dataLines.length === 0) continue;
736
- const payload = dataLines.join("\n");
737
- if (eventName === "complete") {
738
- return JSON.parse(payload);
739
- }
740
- if (eventName === "error") {
741
- let msg = payload;
742
- try {
743
- const parsed = JSON.parse(payload);
744
- msg = typeof parsed === "string" ? parsed : (parsed == null ? void 0 : parsed.message) || payload;
745
- } catch {
746
- }
747
- throw new Error(msg || "OmniVoice 合成失败");
799
+ const requestTTSAudio = async (text, signal) => {
800
+ var _a;
801
+ let lastError = null;
802
+ for (let attempt = 1; attempt <= TTS_MAX_RETRIES; attempt++) {
803
+ if (signal == null ? void 0 : signal.aborted) throw new DOMException("Aborted", "AbortError");
804
+ const res = await fetch(`${TTS_URL}/web/voice/jd/text-to-speech`, {
805
+ method: "POST",
806
+ headers: {
807
+ "Content-Type": "application/json",
808
+ clientid: VOICE_CLIENT_ID,
809
+ "tenant-id": VOICE_TENANT_ID
810
+ },
811
+ body: JSON.stringify({ text, user: TTS_USER, timbre: ((_a = currentHuman.value) == null ? void 0 : _a.timbre) ?? 1 }),
812
+ signal
813
+ });
814
+ if (!res.ok) {
815
+ const errText = await res.text().catch(() => "");
816
+ lastError = new Error(`TTS HTTP ${res.status} ${errText}`);
817
+ if (res.status >= 500 && attempt < TTS_MAX_RETRIES) {
818
+ console.warn(`[TTS] ${res.status},重试 ${attempt}/${TTS_MAX_RETRIES}`);
819
+ await new Promise((r) => setTimeout(r, 400 * attempt));
820
+ continue;
748
821
  }
749
- eventName = "message";
822
+ throw lastError;
823
+ }
824
+ const contentType = (res.headers.get("content-type") || "").toLowerCase();
825
+ if (contentType.includes("audio") || contentType.includes("octet-stream")) {
826
+ const raw = await res.blob();
827
+ console.log("[TTS] 收到音频", { size: raw.size, contentType });
828
+ return ensurePlayableWavBlob(raw);
829
+ }
830
+ const json = await res.json();
831
+ const data = json == null ? void 0 : json.data;
832
+ const audioUrl = typeof data === "object" && ((data == null ? void 0 : data.url) || (data == null ? void 0 : data.audioUrl)) || (json == null ? void 0 : json.url) || (json == null ? void 0 : json.audioUrl);
833
+ const b64 = (typeof data === "string" ? data : null) || (data == null ? void 0 : data.audio) || (data == null ? void 0 : data.base64) || (json == null ? void 0 : json.audio) || (json == null ? void 0 : json.base64);
834
+ if (audioUrl && typeof audioUrl === "string" && /^https?:\/\//.test(audioUrl)) {
835
+ const audioRes = await fetch(audioUrl, { signal });
836
+ if (!audioRes.ok) throw new Error(`TTS 音频下载失败 HTTP ${audioRes.status}`);
837
+ return ensurePlayableWavBlob(await audioRes.blob());
838
+ }
839
+ if (b64 && typeof b64 === "string") {
840
+ const mime = (data == null ? void 0 : data.format) ? `audio/${data.format}` : "audio/wav";
841
+ return ensurePlayableWavBlob(base64ToBlob(b64, mime));
750
842
  }
843
+ throw new Error((json == null ? void 0 : json.msg) || (json == null ? void 0 : json.message) || "TTS 未返回可播放音频");
751
844
  }
752
- throw new Error("OmniVoice 合成结果流提前中断");
845
+ throw lastError || new Error("TTS 请求失败");
753
846
  };
754
- const playAudioFromUrl = async (audioUrl, requestId, signal) => {
755
- const audioRes = await fetch(audioUrl, { signal });
756
- if (!audioRes.ok) throw new Error(`合成音频下载失败 HTTP ${audioRes.status}`);
757
- const audioBlob = await audioRes.blob();
847
+ const playAudioBlob = async (audioBlob, requestId, signal) => {
758
848
  const objectUrl = URL.createObjectURL(audioBlob);
759
849
  if (signal.aborted || currentTtsRequestId.value !== requestId) {
760
850
  URL.revokeObjectURL(objectUrl);
@@ -767,32 +857,29 @@ const _sfc_main = {
767
857
  if (currentAudio.value === audio) currentAudio.value = null;
768
858
  };
769
859
  audio.onplaying = () => {
770
- console.log("[OmniVoice] 音频开始播放");
860
+ console.log("[TTS] 音频开始播放");
771
861
  speak();
772
862
  };
773
863
  audio.onended = () => {
774
- console.log("[OmniVoice] 音频播放结束");
864
+ console.log("[TTS] 音频播放结束");
775
865
  cleanup();
776
866
  stopSpeaking();
777
867
  };
778
868
  audio.onerror = () => {
779
- console.error("[OmniVoice] 音频播放失败");
869
+ console.error("[TTS] 音频播放失败", audio.error);
780
870
  cleanup();
781
871
  stopSpeaking();
782
872
  };
783
- await audio.play();
873
+ try {
874
+ await audio.play();
875
+ } catch (e) {
876
+ console.error("[TTS] audio.play 被拒绝或失败:", e);
877
+ cleanup();
878
+ throw e;
879
+ }
784
880
  };
785
881
  const playTTS = async (text) => {
786
- var _a, _b, _c, _d;
787
- console.log(props.humans);
788
- let voiceUrl = "";
789
- let voiceUrl2 = "http://192.168.3.182:8002/gradio_api/file=C:\\Users\\Administrator\\AppData\\Local\\Temp\\gradio\\4eef6d9157de756de02bccca2f66389382e2ac4acb0c674981aa9fe13dcc294c\\李梓萌.mp3";
790
- let voiceUrl1 = "http://192.168.3.182:8002/gradio_api/file=C:\\Users\\Administrator\\AppData\\Local\\Temp\\gradio\\ff2665f538a4b0e5efbcd79f9f699236f5f0e52cad7e17f0664ddf431169be30\\guozj.mp3";
791
- if (props.humans[0].speakSet.sex == "Female / 女") {
792
- voiceUrl = voiceUrl2;
793
- } else {
794
- voiceUrl = voiceUrl1;
795
- }
882
+ var _a;
796
883
  if (!text || !((_a = currentHuman.value) == null ? void 0 : _a.isMuted)) return;
797
884
  abortTTS();
798
885
  ttsAbortController = new AbortController();
@@ -802,39 +889,19 @@ const _sfc_main = {
802
889
  try {
803
890
  const cleanText = text.replace(/```[\s\S]*?```/g, "").replace(/`[^`]+`/g, "").replace(/[#*_~]/g, "").replace(/\n+/g, ",").replace(/\s+/g, " ").replace(/[,。!?]{2,}/g, ",").trim();
804
891
  if (!cleanText) return;
805
- const speed = Number((_c = (_b = currentHuman.value) == null ? void 0 : _b.speakSet) == null ? void 0 : _c.speed) || 1;
806
- const voiceSource = voiceUrl || ((_d = currentHuman.value) == null ? void 0 : _d.voiceUrl);
807
- if (!voiceSource) {
808
- console.error("[OmniVoice] 未配置参考音频 voiceUrl,无法进行音色克隆");
809
- standby();
810
- return;
811
- }
812
- let refFile = voiceFileCache.get(voiceSource);
813
- if (!refFile) {
814
- console.log("[OmniVoice] 准备参考音频:", typeof voiceSource === "string" ? voiceSource : "Blob/File");
815
- refFile = await resolveVoiceFileData(voiceSource, signal);
816
- voiceFileCache.set(voiceSource, refFile);
817
- }
818
- console.log("[OmniVoice] 参考音频就绪:", refFile);
819
- const data = [cleanText, "Auto", refFile, "", null, 32, 2, true, speed, null, true, true];
820
- console.log("[OmniVoice] 提交克隆请求:", { text: cleanText, length: cleanText.length });
821
- const eventId = await submitCloneJob(data, signal);
822
- const outputs = await waitCloneResult(eventId, signal);
823
- const audioFile = outputs == null ? void 0 : outputs[0];
824
- const statusText = outputs == null ? void 0 : outputs[1];
825
- console.log("[OmniVoice] 合成结果:", { audioFile, statusText });
892
+ console.log("[TTS] 提交合成请求:", { text: cleanText, length: cleanText.length });
893
+ const audioBlob = await requestTTSAudio(cleanText, signal);
826
894
  if (signal.aborted || currentTtsRequestId.value !== requestId) return;
827
- if (!audioFile || !audioFile.path && !audioFile.url) {
828
- throw new Error(statusText || "OmniVoice 未返回合成音频");
895
+ if (!audioBlob || audioBlob.size === 0) {
896
+ throw new Error("TTS 返回空音频");
829
897
  }
830
- const audioUrl = audioFile.url && audioFile.url.startsWith("http") ? audioFile.url : `${baseTTSUrl}/gradio_api/file=${audioFile.path}`;
831
- await playAudioFromUrl(audioUrl, requestId, signal);
898
+ await playAudioBlob(audioBlob, requestId, signal);
832
899
  } catch (e) {
833
900
  if ((e == null ? void 0 : e.name) === "AbortError") {
834
- console.log("[OmniVoice] TTS已取消");
901
+ console.log("[TTS] 已取消");
835
902
  return;
836
903
  }
837
- console.error("[OmniVoice] TTS失败:", e);
904
+ console.error("[TTS] 失败:", e);
838
905
  standby();
839
906
  }
840
907
  };
@@ -864,15 +931,28 @@ const _sfc_main = {
864
931
  const audioToText = async (blob) => {
865
932
  const formData = new FormData();
866
933
  formData.append("file", blob, "recorded_audio.wav");
934
+ const params = new URLSearchParams({
935
+ domain: "general",
936
+ sampleRate: "16000"
937
+ });
938
+ const url = `${ASR_URL}/web/voice/jd/speech-to-text?${params}`;
867
939
  try {
868
- const res = await fetch(ASR_URL + "/transcribe", {
940
+ const res = await fetch(url, {
869
941
  method: "POST",
942
+ headers: {
943
+ clientid: VOICE_CLIENT_ID,
944
+ "tenant-id": VOICE_TENANT_ID
945
+ },
870
946
  body: formData
871
947
  });
948
+ if (!res.ok) {
949
+ throw new Error(`STT HTTP ${res.status}`);
950
+ }
872
951
  const resJson = await res.json();
873
- const text = (resJson == null ? void 0 : resJson.text) || (resJson == null ? void 0 : resJson.text);
874
- const message = (resJson == null ? void 0 : resJson.success) || (resJson == null ? void 0 : resJson.success);
875
- return { status: message, text };
952
+ const data = resJson == null ? void 0 : resJson.data;
953
+ const text = (resJson == null ? void 0 : resJson.text) ?? (typeof data === "string" ? data : data == null ? void 0 : data.text) ?? (resJson == null ? void 0 : resJson.result) ?? "";
954
+ const status = (resJson == null ? void 0 : resJson.success) ?? (resJson == null ? void 0 : resJson.code) ?? res.ok;
955
+ return { status, text };
876
956
  } catch (e) {
877
957
  console.error("STT失败:", e);
878
958
  return { status: e, text: "" };
@@ -889,6 +969,27 @@ const _sfc_main = {
889
969
  });
890
970
  }
891
971
  };
972
+ vue.watch(
973
+ () => [props.human],
974
+ () => {
975
+ init();
976
+ },
977
+ { deep: true }
978
+ );
979
+ const switchHuman = (humanId) => {
980
+ const next = resolveHuman(humanId);
981
+ if (!next) return;
982
+ abortTTS();
983
+ stop();
984
+ timestamp.value = null;
985
+ frameIndex.value = 1;
986
+ currentHuman.value = next;
987
+ loadFrames();
988
+ waitFramesLoaded(() => {
989
+ draw(frames.value.standby[0]);
990
+ emit("changed", currentHuman.value);
991
+ });
992
+ };
892
993
  __expose({
893
994
  standby,
894
995
  speak,
@@ -897,13 +998,9 @@ const _sfc_main = {
897
998
  resume,
898
999
  playTTS,
899
1000
  startRecord,
900
- stopRecord
1001
+ stopRecord,
1002
+ switchHuman
901
1003
  });
902
- vue.watch(() => props.humans, (val) => {
903
- if (val && val.length > 0 && !currentHuman.value) {
904
- init();
905
- }
906
- }, { deep: true });
907
1004
  vue.onMounted(() => {
908
1005
  initRecorder();
909
1006
  init();
@@ -929,7 +1026,7 @@ const _sfc_main = {
929
1026
  };
930
1027
  }
931
1028
  };
932
- const DigitalHuman = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-9c25016c"]]);
1029
+ const DigitalHuman = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-eb00f641"]]);
933
1030
  DigitalHuman.install = (app) => app.component("DigitalHuman", DigitalHuman);
934
1031
  const components = [DemoButton, DemoInput, DigitalHuman];
935
1032
  const install = (app) => {
@@ -383,18 +383,97 @@ var recorder = { exports: {} };
383
383
  var recorderExports = recorder.exports;
384
384
  var jsAudioRecorder = recorderExports;
385
385
  const Recorder = /* @__PURE__ */ getDefaultExportFromCjs(jsAudioRecorder);
386
- const baseTTSUrl = "http://192.168.3.182:8002";
387
- const ASR_URL = "http://192.168.3.182:9988";
386
+ const VOICE_CLIENT_ID = "e5cd2b";
387
+ const VOICE_TENANT_ID = "000000";
388
+ const TTS_USER = "web-test-user";
389
+ const TTS_PCM_SAMPLE_RATE = 16e3;
390
+ const TTS_MAX_RETRIES = 5;
388
391
  const _sfc_main = {
389
392
  __name: "index",
390
393
  props: {
391
- humans: {
392
- type: Array,
393
- default: () => []
394
+ human: {
395
+ type: Object,
396
+ default: null
394
397
  }
395
398
  },
396
399
  emits: ["loaded", "changed", "paused", "resumed", "audio-ready"],
397
400
  setup(__props, { expose: __expose, emit: __emit }) {
401
+ const TTS_URL = "http://localhost:6039";
402
+ const ASR_URL = "http://localhost:6039";
403
+ const HUMAN_PRESETS = {
404
+ 1: {
405
+ humanId: 1,
406
+ title: "西装动漫男",
407
+ // TODO: 替换为真实资源
408
+ folder: "http://192.168.3.107:8088/img/xiangyu-green-suit",
409
+ folder_mobile: "http://192.168.3.107:8088/img/xiangyu-green-suit-header",
410
+ standby: { interval: 100, standbyNum: 11 },
411
+ speak: { interval: 30, speakNum: 0 },
412
+ humanType: 1,
413
+ timbre: 1
414
+ },
415
+ 2: {
416
+ humanId: 2,
417
+ title: "西装动漫女",
418
+ folder: "http://192.168.3.107:8088/img/anime-woman",
419
+ folder_mobile: "http://192.168.3.107:8088/img/anime-woman-header",
420
+ standby: { interval: 100, standbyNum: 1 },
421
+ speak: { interval: 30, speakNum: 75 },
422
+ humanType: 1,
423
+ timbre: 3
424
+ },
425
+ 3: {
426
+ humanId: 3,
427
+ title: "真人形象男",
428
+ folder: "http://192.168.3.107:8088/img/real-man",
429
+ folder_mobile: "http://192.168.3.107:8088/img/real-man-header",
430
+ standby: { interval: 100, standbyNum: 1 },
431
+ speak: { interval: 30, speakNum: 139 },
432
+ humanType: 1,
433
+ timbre: 47
434
+ },
435
+ 4: {
436
+ humanId: 4,
437
+ title: "真人形象女",
438
+ folder: "http://192.168.3.107:8088/img/real-woman",
439
+ folder_mobile: "http://192.168.3.107:8088/img/real-woman-header",
440
+ standby: { interval: 100, standbyNum: 2 },
441
+ speak: { interval: 40, speakNum: 139 },
442
+ humanType: 1,
443
+ timbre: 34
444
+ },
445
+ 5: {
446
+ humanId: 5,
447
+ title: "Q版形象",
448
+ folder: "http://192.168.3.107:8088/img/xiangyu-q",
449
+ folder_mobile: "http://192.168.3.107:8088/img/xiangyu-q-header",
450
+ standby: { interval: 100, standbyNum: 28 },
451
+ speak: { interval: 30, speakNum: 118 },
452
+ humanType: 1,
453
+ timbre: 34
454
+ }
455
+ };
456
+ const isMobile = () => {
457
+ if (typeof navigator === "undefined") return false;
458
+ return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|HarmonyOS/i.test(navigator.userAgent);
459
+ };
460
+ const resolveFolder = (human) => {
461
+ const finalType = human.humanType || (isMobile() ? 2 : 1);
462
+ if (finalType === 2 && human.folder_mobile) return human.folder_mobile;
463
+ return human.folder;
464
+ };
465
+ const resolveHuman = (input) => {
466
+ if (input == null) return null;
467
+ const humanId = typeof input === "number" ? input : input.humanId;
468
+ const preset = HUMAN_PRESETS[humanId];
469
+ if (!preset) {
470
+ console.error("[DigitalHuman] 未找到 humanId=", humanId, "的预设形象,可用 id:", Object.keys(HUMAN_PRESETS));
471
+ return null;
472
+ }
473
+ const merged = { ...preset, ...typeof input === "object" ? input : {} };
474
+ merged.folder = resolveFolder(merged);
475
+ return merged;
476
+ };
398
477
  const props = __props;
399
478
  const emit = __emit;
400
479
  const containerRef = ref(null);
@@ -416,27 +495,38 @@ const _sfc_main = {
416
495
  standbyNum: currentHuman.value.standby.standbyNum,
417
496
  speakNum: currentHuman.value.speak.speakNum
418
497
  };
419
- for (let i = 1; i <= standbyNum; i++) {
498
+ const makeImage = (url) => {
420
499
  const image = new Image();
421
- image.src = encodeURI(`${folder}/standby/standby (${i}).png`);
422
- frames.value.standby.push(image);
500
+ image.src = encodeURI(url);
501
+ image.onload = () => {
502
+ image.__broken = false;
503
+ };
504
+ image.onerror = () => {
505
+ image.__broken = true;
506
+ console.warn("[DigitalHuman] 帧加载失败:", url);
507
+ };
508
+ return image;
509
+ };
510
+ for (let i = 1; i <= standbyNum; i++) {
511
+ frames.value.standby.push(makeImage(`${folder}/standby/standby (${i}).png`));
512
+ }
513
+ if (speakNum <= 0) {
514
+ console.warn("[DigitalHuman] 当前形象 speakNum=0,说话动画暂用待机帧替代");
515
+ frames.value.speakStart = [...frames.value.standby];
516
+ frames.value.speakLoop = [...frames.value.standby];
517
+ frames.value.speakEnd = [...frames.value.standby];
518
+ return;
423
519
  }
424
520
  const startEnd = Math.floor(speakNum * 0.6);
425
521
  const loopEnd = Math.floor(speakNum * 0.9);
426
522
  for (let i = 1; i <= startEnd; i++) {
427
- const image = new Image();
428
- image.src = encodeURI(`${folder}/speak/speak (${i}).png`);
429
- frames.value.speakStart.push(image);
523
+ frames.value.speakStart.push(makeImage(`${folder}/speak/speak (${i}).png`));
430
524
  }
431
525
  for (let i = startEnd; i <= loopEnd; i++) {
432
- const image = new Image();
433
- image.src = encodeURI(`${folder}/speak/speak (${i}).png`);
434
- frames.value.speakLoop.push(image);
526
+ frames.value.speakLoop.push(makeImage(`${folder}/speak/speak (${i}).png`));
435
527
  }
436
528
  for (let i = loopEnd; i <= speakNum; i++) {
437
- const image = new Image();
438
- image.src = encodeURI(`${folder}/speak/speak (${i}).png`);
439
- frames.value.speakEnd.push(image);
529
+ frames.value.speakEnd.push(makeImage(`${folder}/speak/speak (${i}).png`));
440
530
  }
441
531
  };
442
532
  const draw = (image) => {
@@ -597,20 +687,38 @@ const _sfc_main = {
597
687
  };
598
688
  allFrames.forEach((img) => {
599
689
  if (img.complete) {
690
+ if (!img.naturalWidth) img.__broken = true;
600
691
  checkLoaded();
601
692
  } else {
602
- img.onload = checkLoaded;
603
- img.onerror = checkLoaded;
693
+ img.onload = () => {
694
+ img.__broken = false;
695
+ checkLoaded();
696
+ };
697
+ img.onerror = () => {
698
+ img.__broken = true;
699
+ checkLoaded();
700
+ };
604
701
  }
605
702
  });
606
703
  };
607
704
  const init = () => {
608
705
  nextTick(() => {
706
+ var _a, _b;
609
707
  if (!containerRef.value || !canvasRef.value) return;
610
- if (!props.humans || props.humans.length === 0) return;
611
- if (!currentHuman.value) {
612
- currentHuman.value = props.humans[0];
708
+ const rawInput = props.human;
709
+ if (!rawInput) return;
710
+ const human = resolveHuman(rawInput);
711
+ if (!human) return;
712
+ const prevType = (_a = currentHuman.value) == null ? void 0 : _a.humanType;
713
+ if (((_b = currentHuman.value) == null ? void 0 : _b.humanId) === human.humanId && prevType === human.humanType) {
714
+ Object.assign(currentHuman.value, human);
715
+ return;
613
716
  }
717
+ abortTTS();
718
+ stop();
719
+ timestamp.value = null;
720
+ frameIndex.value = 1;
721
+ currentHuman.value = human;
614
722
  const canvas = canvasRef.value;
615
723
  const dpr = window.devicePixelRatio || 1;
616
724
  const width = containerRef.value.clientWidth;
@@ -647,112 +755,94 @@ const _sfc_main = {
647
755
  currentAudio.value = null;
648
756
  }
649
757
  }
650
- const voiceFileCache = /* @__PURE__ */ new Map();
651
- const FILEDATA_META = { _type: "gradio.FileData" };
652
- const uploadReferenceAudio = async (blob, filename, signal) => {
653
- const form = new FormData();
654
- form.append("files", blob, filename);
655
- const res = await fetch(`${baseTTSUrl}/gradio_api/upload`, {
656
- method: "POST",
657
- body: form,
658
- signal
659
- });
660
- if (!res.ok) throw new Error(`参考音频上传失败 HTTP ${res.status}`);
661
- const paths = await res.json();
662
- const path = Array.isArray(paths) ? paths[0] : paths;
663
- if (!path) throw new Error("参考音频上传失败:服务未返回文件路径");
664
- return { path, orig_name: filename, meta: FILEDATA_META };
758
+ const base64ToBlob = (b64, mime = "audio/wav") => {
759
+ const raw = String(b64).replace(/^data:audio\/[\w+-]+;base64,/, "");
760
+ const binary = atob(raw);
761
+ const bytes = new Uint8Array(binary.length);
762
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
763
+ return new Blob([bytes], { type: mime });
665
764
  };
666
- const resolveVoiceFileData = async (voiceSource, signal) => {
667
- if (voiceSource && typeof voiceSource === "object" && !Array.isArray(voiceSource) && voiceSource.path) {
668
- return {
669
- path: voiceSource.path,
670
- orig_name: voiceSource.orig_name || "reference.mp3",
671
- meta: FILEDATA_META
672
- };
673
- }
674
- if (voiceSource instanceof Blob) {
675
- const name = voiceSource.name || `reference_${Date.now()}.mp3`;
676
- return await uploadReferenceAudio(voiceSource, name, signal);
677
- }
678
- if (typeof voiceSource === "string" && voiceSource) {
679
- if (voiceSource.startsWith(baseTTSUrl)) {
680
- const match = voiceSource.match(/\/gradio_api\/file=(.+)$/);
681
- if (match) {
682
- const path = decodeURIComponent(match[1]);
683
- const origName = path.split(/[\\/]/).pop() || "reference.mp3";
684
- return { path, orig_name: origName, meta: FILEDATA_META };
685
- }
686
- }
687
- const res = await fetch(voiceSource, { signal });
688
- if (!res.ok) throw new Error(`参考音频下载失败 HTTP ${res.status}`);
689
- const blob = await res.blob();
690
- const fileName = decodeURIComponent(voiceSource.split("?")[0].split("/").pop()) || "reference.mp3";
691
- return await uploadReferenceAudio(blob, fileName, signal);
692
- }
693
- throw new Error("不支持的参考音频来源:voiceUrl 应为音频 URL 字符串、File/Blob 或 { path } 对象");
765
+ const pcmToWavBlob = (pcmBuffer, sampleRate = TTS_PCM_SAMPLE_RATE) => {
766
+ const dataLength = pcmBuffer.byteLength;
767
+ const header = new ArrayBuffer(44);
768
+ const view = new DataView(header);
769
+ const writeStr = (offset, str) => {
770
+ for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
771
+ };
772
+ writeStr(0, "RIFF");
773
+ view.setUint32(4, 36 + dataLength, true);
774
+ writeStr(8, "WAVE");
775
+ writeStr(12, "fmt ");
776
+ view.setUint32(16, 16, true);
777
+ view.setUint16(20, 1, true);
778
+ view.setUint16(22, 1, true);
779
+ view.setUint32(24, sampleRate, true);
780
+ view.setUint32(28, sampleRate * 2, true);
781
+ view.setUint16(32, 2, true);
782
+ view.setUint16(34, 16, true);
783
+ writeStr(36, "data");
784
+ view.setUint32(40, dataLength, true);
785
+ return new Blob([header, pcmBuffer], { type: "audio/wav" });
694
786
  };
695
- const submitCloneJob = async (data, signal) => {
696
- const res = await fetch(`${baseTTSUrl}/gradio_api/call/_clone_fn`, {
697
- method: "POST",
698
- headers: { "Content-Type": "application/json" },
699
- body: JSON.stringify({ data }),
700
- signal
701
- });
702
- if (!res.ok) {
703
- const text = await res.text().catch(() => "");
704
- throw new Error(`克隆任务提交失败 HTTP ${res.status} ${text}`);
787
+ const ensurePlayableWavBlob = async (blob) => {
788
+ const buf = await blob.arrayBuffer();
789
+ if (buf.byteLength < 4) return blob;
790
+ const head = String.fromCharCode(...new Uint8Array(buf, 0, 4));
791
+ if (head === "RIFF") {
792
+ return new Blob([buf], { type: "audio/wav" });
705
793
  }
706
- const json = await res.json();
707
- if (!(json == null ? void 0 : json.event_id)) throw new Error("克隆任务提交失败:未返回 event_id");
708
- return json.event_id;
794
+ console.log("[TTS] 检测到裸 PCM,补 WAV 头后播放", { size: buf.byteLength });
795
+ return pcmToWavBlob(buf);
709
796
  };
710
- const waitCloneResult = async (eventId, signal) => {
711
- const res = await fetch(`${baseTTSUrl}/gradio_api/call/_clone_fn/${eventId}`, {
712
- signal,
713
- headers: { Accept: "text/event-stream" }
714
- });
715
- if (!res.ok) throw new Error(`合成结果监听失败 HTTP ${res.status}`);
716
- const reader = res.body.getReader();
717
- const decoder = new TextDecoder("utf-8");
718
- let buffer = "";
719
- let eventName = "message";
720
- while (true) {
721
- const { done, value } = await reader.read();
722
- if (done) break;
723
- buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
724
- let sep;
725
- while ((sep = buffer.indexOf("\n\n")) !== -1) {
726
- const frame = buffer.slice(0, sep);
727
- buffer = buffer.slice(sep + 2);
728
- const dataLines = [];
729
- for (const line of frame.split("\n")) {
730
- if (line.startsWith("event:")) eventName = line.slice(6).trim();
731
- else if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
732
- }
733
- if (dataLines.length === 0) continue;
734
- const payload = dataLines.join("\n");
735
- if (eventName === "complete") {
736
- return JSON.parse(payload);
737
- }
738
- if (eventName === "error") {
739
- let msg = payload;
740
- try {
741
- const parsed = JSON.parse(payload);
742
- msg = typeof parsed === "string" ? parsed : (parsed == null ? void 0 : parsed.message) || payload;
743
- } catch {
744
- }
745
- throw new Error(msg || "OmniVoice 合成失败");
797
+ const requestTTSAudio = async (text, signal) => {
798
+ var _a;
799
+ let lastError = null;
800
+ for (let attempt = 1; attempt <= TTS_MAX_RETRIES; attempt++) {
801
+ if (signal == null ? void 0 : signal.aborted) throw new DOMException("Aborted", "AbortError");
802
+ const res = await fetch(`${TTS_URL}/web/voice/jd/text-to-speech`, {
803
+ method: "POST",
804
+ headers: {
805
+ "Content-Type": "application/json",
806
+ clientid: VOICE_CLIENT_ID,
807
+ "tenant-id": VOICE_TENANT_ID
808
+ },
809
+ body: JSON.stringify({ text, user: TTS_USER, timbre: ((_a = currentHuman.value) == null ? void 0 : _a.timbre) ?? 1 }),
810
+ signal
811
+ });
812
+ if (!res.ok) {
813
+ const errText = await res.text().catch(() => "");
814
+ lastError = new Error(`TTS HTTP ${res.status} ${errText}`);
815
+ if (res.status >= 500 && attempt < TTS_MAX_RETRIES) {
816
+ console.warn(`[TTS] ${res.status},重试 ${attempt}/${TTS_MAX_RETRIES}`);
817
+ await new Promise((r) => setTimeout(r, 400 * attempt));
818
+ continue;
746
819
  }
747
- eventName = "message";
820
+ throw lastError;
821
+ }
822
+ const contentType = (res.headers.get("content-type") || "").toLowerCase();
823
+ if (contentType.includes("audio") || contentType.includes("octet-stream")) {
824
+ const raw = await res.blob();
825
+ console.log("[TTS] 收到音频", { size: raw.size, contentType });
826
+ return ensurePlayableWavBlob(raw);
827
+ }
828
+ const json = await res.json();
829
+ const data = json == null ? void 0 : json.data;
830
+ const audioUrl = typeof data === "object" && ((data == null ? void 0 : data.url) || (data == null ? void 0 : data.audioUrl)) || (json == null ? void 0 : json.url) || (json == null ? void 0 : json.audioUrl);
831
+ const b64 = (typeof data === "string" ? data : null) || (data == null ? void 0 : data.audio) || (data == null ? void 0 : data.base64) || (json == null ? void 0 : json.audio) || (json == null ? void 0 : json.base64);
832
+ if (audioUrl && typeof audioUrl === "string" && /^https?:\/\//.test(audioUrl)) {
833
+ const audioRes = await fetch(audioUrl, { signal });
834
+ if (!audioRes.ok) throw new Error(`TTS 音频下载失败 HTTP ${audioRes.status}`);
835
+ return ensurePlayableWavBlob(await audioRes.blob());
836
+ }
837
+ if (b64 && typeof b64 === "string") {
838
+ const mime = (data == null ? void 0 : data.format) ? `audio/${data.format}` : "audio/wav";
839
+ return ensurePlayableWavBlob(base64ToBlob(b64, mime));
748
840
  }
841
+ throw new Error((json == null ? void 0 : json.msg) || (json == null ? void 0 : json.message) || "TTS 未返回可播放音频");
749
842
  }
750
- throw new Error("OmniVoice 合成结果流提前中断");
843
+ throw lastError || new Error("TTS 请求失败");
751
844
  };
752
- const playAudioFromUrl = async (audioUrl, requestId, signal) => {
753
- const audioRes = await fetch(audioUrl, { signal });
754
- if (!audioRes.ok) throw new Error(`合成音频下载失败 HTTP ${audioRes.status}`);
755
- const audioBlob = await audioRes.blob();
845
+ const playAudioBlob = async (audioBlob, requestId, signal) => {
756
846
  const objectUrl = URL.createObjectURL(audioBlob);
757
847
  if (signal.aborted || currentTtsRequestId.value !== requestId) {
758
848
  URL.revokeObjectURL(objectUrl);
@@ -765,32 +855,29 @@ const _sfc_main = {
765
855
  if (currentAudio.value === audio) currentAudio.value = null;
766
856
  };
767
857
  audio.onplaying = () => {
768
- console.log("[OmniVoice] 音频开始播放");
858
+ console.log("[TTS] 音频开始播放");
769
859
  speak();
770
860
  };
771
861
  audio.onended = () => {
772
- console.log("[OmniVoice] 音频播放结束");
862
+ console.log("[TTS] 音频播放结束");
773
863
  cleanup();
774
864
  stopSpeaking();
775
865
  };
776
866
  audio.onerror = () => {
777
- console.error("[OmniVoice] 音频播放失败");
867
+ console.error("[TTS] 音频播放失败", audio.error);
778
868
  cleanup();
779
869
  stopSpeaking();
780
870
  };
781
- await audio.play();
871
+ try {
872
+ await audio.play();
873
+ } catch (e) {
874
+ console.error("[TTS] audio.play 被拒绝或失败:", e);
875
+ cleanup();
876
+ throw e;
877
+ }
782
878
  };
783
879
  const playTTS = async (text) => {
784
- var _a, _b, _c, _d;
785
- console.log(props.humans);
786
- let voiceUrl = "";
787
- let voiceUrl2 = "http://192.168.3.182:8002/gradio_api/file=C:\\Users\\Administrator\\AppData\\Local\\Temp\\gradio\\4eef6d9157de756de02bccca2f66389382e2ac4acb0c674981aa9fe13dcc294c\\李梓萌.mp3";
788
- let voiceUrl1 = "http://192.168.3.182:8002/gradio_api/file=C:\\Users\\Administrator\\AppData\\Local\\Temp\\gradio\\ff2665f538a4b0e5efbcd79f9f699236f5f0e52cad7e17f0664ddf431169be30\\guozj.mp3";
789
- if (props.humans[0].speakSet.sex == "Female / 女") {
790
- voiceUrl = voiceUrl2;
791
- } else {
792
- voiceUrl = voiceUrl1;
793
- }
880
+ var _a;
794
881
  if (!text || !((_a = currentHuman.value) == null ? void 0 : _a.isMuted)) return;
795
882
  abortTTS();
796
883
  ttsAbortController = new AbortController();
@@ -800,39 +887,19 @@ const _sfc_main = {
800
887
  try {
801
888
  const cleanText = text.replace(/```[\s\S]*?```/g, "").replace(/`[^`]+`/g, "").replace(/[#*_~]/g, "").replace(/\n+/g, ",").replace(/\s+/g, " ").replace(/[,。!?]{2,}/g, ",").trim();
802
889
  if (!cleanText) return;
803
- const speed = Number((_c = (_b = currentHuman.value) == null ? void 0 : _b.speakSet) == null ? void 0 : _c.speed) || 1;
804
- const voiceSource = voiceUrl || ((_d = currentHuman.value) == null ? void 0 : _d.voiceUrl);
805
- if (!voiceSource) {
806
- console.error("[OmniVoice] 未配置参考音频 voiceUrl,无法进行音色克隆");
807
- standby();
808
- return;
809
- }
810
- let refFile = voiceFileCache.get(voiceSource);
811
- if (!refFile) {
812
- console.log("[OmniVoice] 准备参考音频:", typeof voiceSource === "string" ? voiceSource : "Blob/File");
813
- refFile = await resolveVoiceFileData(voiceSource, signal);
814
- voiceFileCache.set(voiceSource, refFile);
815
- }
816
- console.log("[OmniVoice] 参考音频就绪:", refFile);
817
- const data = [cleanText, "Auto", refFile, "", null, 32, 2, true, speed, null, true, true];
818
- console.log("[OmniVoice] 提交克隆请求:", { text: cleanText, length: cleanText.length });
819
- const eventId = await submitCloneJob(data, signal);
820
- const outputs = await waitCloneResult(eventId, signal);
821
- const audioFile = outputs == null ? void 0 : outputs[0];
822
- const statusText = outputs == null ? void 0 : outputs[1];
823
- console.log("[OmniVoice] 合成结果:", { audioFile, statusText });
890
+ console.log("[TTS] 提交合成请求:", { text: cleanText, length: cleanText.length });
891
+ const audioBlob = await requestTTSAudio(cleanText, signal);
824
892
  if (signal.aborted || currentTtsRequestId.value !== requestId) return;
825
- if (!audioFile || !audioFile.path && !audioFile.url) {
826
- throw new Error(statusText || "OmniVoice 未返回合成音频");
893
+ if (!audioBlob || audioBlob.size === 0) {
894
+ throw new Error("TTS 返回空音频");
827
895
  }
828
- const audioUrl = audioFile.url && audioFile.url.startsWith("http") ? audioFile.url : `${baseTTSUrl}/gradio_api/file=${audioFile.path}`;
829
- await playAudioFromUrl(audioUrl, requestId, signal);
896
+ await playAudioBlob(audioBlob, requestId, signal);
830
897
  } catch (e) {
831
898
  if ((e == null ? void 0 : e.name) === "AbortError") {
832
- console.log("[OmniVoice] TTS已取消");
899
+ console.log("[TTS] 已取消");
833
900
  return;
834
901
  }
835
- console.error("[OmniVoice] TTS失败:", e);
902
+ console.error("[TTS] 失败:", e);
836
903
  standby();
837
904
  }
838
905
  };
@@ -862,15 +929,28 @@ const _sfc_main = {
862
929
  const audioToText = async (blob) => {
863
930
  const formData = new FormData();
864
931
  formData.append("file", blob, "recorded_audio.wav");
932
+ const params = new URLSearchParams({
933
+ domain: "general",
934
+ sampleRate: "16000"
935
+ });
936
+ const url = `${ASR_URL}/web/voice/jd/speech-to-text?${params}`;
865
937
  try {
866
- const res = await fetch(ASR_URL + "/transcribe", {
938
+ const res = await fetch(url, {
867
939
  method: "POST",
940
+ headers: {
941
+ clientid: VOICE_CLIENT_ID,
942
+ "tenant-id": VOICE_TENANT_ID
943
+ },
868
944
  body: formData
869
945
  });
946
+ if (!res.ok) {
947
+ throw new Error(`STT HTTP ${res.status}`);
948
+ }
870
949
  const resJson = await res.json();
871
- const text = (resJson == null ? void 0 : resJson.text) || (resJson == null ? void 0 : resJson.text);
872
- const message = (resJson == null ? void 0 : resJson.success) || (resJson == null ? void 0 : resJson.success);
873
- return { status: message, text };
950
+ const data = resJson == null ? void 0 : resJson.data;
951
+ const text = (resJson == null ? void 0 : resJson.text) ?? (typeof data === "string" ? data : data == null ? void 0 : data.text) ?? (resJson == null ? void 0 : resJson.result) ?? "";
952
+ const status = (resJson == null ? void 0 : resJson.success) ?? (resJson == null ? void 0 : resJson.code) ?? res.ok;
953
+ return { status, text };
874
954
  } catch (e) {
875
955
  console.error("STT失败:", e);
876
956
  return { status: e, text: "" };
@@ -887,6 +967,27 @@ const _sfc_main = {
887
967
  });
888
968
  }
889
969
  };
970
+ watch(
971
+ () => [props.human],
972
+ () => {
973
+ init();
974
+ },
975
+ { deep: true }
976
+ );
977
+ const switchHuman = (humanId) => {
978
+ const next = resolveHuman(humanId);
979
+ if (!next) return;
980
+ abortTTS();
981
+ stop();
982
+ timestamp.value = null;
983
+ frameIndex.value = 1;
984
+ currentHuman.value = next;
985
+ loadFrames();
986
+ waitFramesLoaded(() => {
987
+ draw(frames.value.standby[0]);
988
+ emit("changed", currentHuman.value);
989
+ });
990
+ };
890
991
  __expose({
891
992
  standby,
892
993
  speak,
@@ -895,13 +996,9 @@ const _sfc_main = {
895
996
  resume,
896
997
  playTTS,
897
998
  startRecord,
898
- stopRecord
999
+ stopRecord,
1000
+ switchHuman
899
1001
  });
900
- watch(() => props.humans, (val) => {
901
- if (val && val.length > 0 && !currentHuman.value) {
902
- init();
903
- }
904
- }, { deep: true });
905
1002
  onMounted(() => {
906
1003
  initRecorder();
907
1004
  init();
@@ -927,7 +1024,7 @@ const _sfc_main = {
927
1024
  };
928
1025
  }
929
1026
  };
930
- const DigitalHuman = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-9c25016c"]]);
1027
+ const DigitalHuman = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-eb00f641"]]);
931
1028
  DigitalHuman.install = (app) => app.component("DigitalHuman", DigitalHuman);
932
1029
  const components = [DemoButton, DemoInput, DigitalHuman];
933
1030
  const install = (app) => {
package/dist/style.css CHANGED
@@ -1 +1 @@
1
- .demo-btn[data-v-9a48516a]{padding:6px 14px;border-radius:4px;border:none;cursor:pointer;font-size:14px}.primary[data-v-9a48516a]{background:#409eff;color:#fff}.success[data-v-9a48516a]{background:#67c23a;color:#fff}.warning[data-v-9a48516a]{background:#e6a23c;color:#fff}.demo-input-wrap[data-v-28a3493c]{display:inline-flex;align-items:center;border:1px solid #dcdcdc;border-radius:4px;padding:0 10px}.demo-input-wrap[data-v-28a3493c]:focus-within{border-color:#409eff}.demo-input[data-v-28a3493c]{border:none;outline:none;padding:8px 6px;flex:1}.prefix[data-v-28a3493c],.suffix[data-v-28a3493c]{color:#909399}.digital-human[data-v-9c25016c]{width:100%;height:100%;display:flex;align-items:center;justify-content:center}.digital-human canvas[data-v-9c25016c]{max-width:100%;max-height:100%}
1
+ .demo-btn[data-v-9a48516a]{padding:6px 14px;border-radius:4px;border:none;cursor:pointer;font-size:14px}.primary[data-v-9a48516a]{background:#409eff;color:#fff}.success[data-v-9a48516a]{background:#67c23a;color:#fff}.warning[data-v-9a48516a]{background:#e6a23c;color:#fff}.demo-input-wrap[data-v-28a3493c]{display:inline-flex;align-items:center;border:1px solid #dcdcdc;border-radius:4px;padding:0 10px}.demo-input-wrap[data-v-28a3493c]:focus-within{border-color:#409eff}.demo-input[data-v-28a3493c]{border:none;outline:none;padding:8px 6px;flex:1}.prefix[data-v-28a3493c],.suffix[data-v-28a3493c]{color:#909399}.digital-human[data-v-eb00f641]{width:100%;height:100%;display:flex;align-items:center;justify-content:center}.digital-human canvas[data-v-eb00f641]{max-width:100%;max-height:100%}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruiyun-human",
3
- "version": "1.0.12",
3
+ "version": "1.0.14",
4
4
  "type": "module",
5
5
  "description": "数字人初步实现",
6
6
  "main": "./dist/ruiyun-human.cjs.js",