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.
- package/dist/ruiyun-human.cjs.js +269 -172
- package/dist/ruiyun-human.es.js +269 -172
- package/dist/style.css +1 -1
- package/package.json +1 -1
package/dist/ruiyun-human.cjs.js
CHANGED
|
@@ -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
|
|
389
|
-
const
|
|
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
|
-
|
|
394
|
-
type:
|
|
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
|
-
|
|
500
|
+
const makeImage = (url) => {
|
|
422
501
|
const image = new Image();
|
|
423
|
-
image.src = encodeURI(
|
|
424
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
605
|
-
|
|
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
|
-
|
|
613
|
-
if (!
|
|
614
|
-
|
|
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
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
const
|
|
656
|
-
|
|
657
|
-
|
|
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
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
return json.event_id;
|
|
796
|
+
console.log("[TTS] 检测到裸 PCM,补 WAV 头后播放", { size: buf.byteLength });
|
|
797
|
+
return pcmToWavBlob(buf);
|
|
711
798
|
};
|
|
712
|
-
const
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
const
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
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("
|
|
845
|
+
throw lastError || new Error("TTS 请求失败");
|
|
753
846
|
};
|
|
754
|
-
const
|
|
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("[
|
|
860
|
+
console.log("[TTS] 音频开始播放");
|
|
771
861
|
speak();
|
|
772
862
|
};
|
|
773
863
|
audio.onended = () => {
|
|
774
|
-
console.log("[
|
|
864
|
+
console.log("[TTS] 音频播放结束");
|
|
775
865
|
cleanup();
|
|
776
866
|
stopSpeaking();
|
|
777
867
|
};
|
|
778
868
|
audio.onerror = () => {
|
|
779
|
-
console.error("[
|
|
869
|
+
console.error("[TTS] 音频播放失败", audio.error);
|
|
780
870
|
cleanup();
|
|
781
871
|
stopSpeaking();
|
|
782
872
|
};
|
|
783
|
-
|
|
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
|
|
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
|
-
|
|
806
|
-
const
|
|
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 (!
|
|
828
|
-
throw new Error(
|
|
895
|
+
if (!audioBlob || audioBlob.size === 0) {
|
|
896
|
+
throw new Error("TTS 返回空音频");
|
|
829
897
|
}
|
|
830
|
-
|
|
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("[
|
|
901
|
+
console.log("[TTS] 已取消");
|
|
835
902
|
return;
|
|
836
903
|
}
|
|
837
|
-
console.error("[
|
|
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(
|
|
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
|
|
874
|
-
const
|
|
875
|
-
|
|
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-
|
|
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) => {
|
package/dist/ruiyun-human.es.js
CHANGED
|
@@ -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
|
|
387
|
-
const
|
|
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
|
-
|
|
392
|
-
type:
|
|
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
|
-
|
|
498
|
+
const makeImage = (url) => {
|
|
420
499
|
const image = new Image();
|
|
421
|
-
image.src = encodeURI(
|
|
422
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
603
|
-
|
|
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
|
-
|
|
611
|
-
if (!
|
|
612
|
-
|
|
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
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
const
|
|
654
|
-
|
|
655
|
-
|
|
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
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
|
696
|
-
const
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
return json.event_id;
|
|
794
|
+
console.log("[TTS] 检测到裸 PCM,补 WAV 头后播放", { size: buf.byteLength });
|
|
795
|
+
return pcmToWavBlob(buf);
|
|
709
796
|
};
|
|
710
|
-
const
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
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("
|
|
843
|
+
throw lastError || new Error("TTS 请求失败");
|
|
751
844
|
};
|
|
752
|
-
const
|
|
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("[
|
|
858
|
+
console.log("[TTS] 音频开始播放");
|
|
769
859
|
speak();
|
|
770
860
|
};
|
|
771
861
|
audio.onended = () => {
|
|
772
|
-
console.log("[
|
|
862
|
+
console.log("[TTS] 音频播放结束");
|
|
773
863
|
cleanup();
|
|
774
864
|
stopSpeaking();
|
|
775
865
|
};
|
|
776
866
|
audio.onerror = () => {
|
|
777
|
-
console.error("[
|
|
867
|
+
console.error("[TTS] 音频播放失败", audio.error);
|
|
778
868
|
cleanup();
|
|
779
869
|
stopSpeaking();
|
|
780
870
|
};
|
|
781
|
-
|
|
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
|
|
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
|
-
|
|
804
|
-
const
|
|
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 (!
|
|
826
|
-
throw new Error(
|
|
893
|
+
if (!audioBlob || audioBlob.size === 0) {
|
|
894
|
+
throw new Error("TTS 返回空音频");
|
|
827
895
|
}
|
|
828
|
-
|
|
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("[
|
|
899
|
+
console.log("[TTS] 已取消");
|
|
833
900
|
return;
|
|
834
901
|
}
|
|
835
|
-
console.error("[
|
|
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(
|
|
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
|
|
872
|
-
const
|
|
873
|
-
|
|
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-
|
|
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-
|
|
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%}
|