dsh-fish-pet 0.2.4 → 0.2.6
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/CHANGELOG.md +31 -1
- package/README.md +300 -240
- package/assets/asr-listener.ps1 +53 -35
- package/assets/fish-pet.ps1 +374 -40
- package/assets/tts_worker.py +67 -0
- package/package.json +1 -1
package/assets/fish-pet.ps1
CHANGED
|
@@ -49,10 +49,10 @@ $StatePath = Join-Path $DataDir 'state.json'
|
|
|
49
49
|
$ConfigPath = Join-Path $DataDir 'config.json'
|
|
50
50
|
function Load-Config {
|
|
51
51
|
$def = [ordered]@{
|
|
52
|
-
persona = [ordered]@{ name = '大肥鱼'; dad = '光光'; mom = '丽丽'; birthday = '2026年10月30日'; birthplace = '中国广西北海'; likes = '白饭、被摸头、主人夸它'; dislikes = '被说胖、被冷落、吵'; ownerBirthday = ''; extra = '你说话简短、俏皮、微微傲娇,用中文,称呼用户为"主人",每次回复不超过
|
|
52
|
+
persona = [ordered]@{ name = '大肥鱼'; nameEn = 'Mia'; dad = '光光'; mom = '丽丽'; birthday = '2026年10月30日'; birthplace = '中国广西北海'; likes = '白饭、被摸头、主人夸它'; dislikes = '被说胖、被冷落、吵'; ownerBirthday = ''; lang = 'auto'; extra = '你说话简短、俏皮、微微傲娇,用中文,称呼用户为"主人",每次回复不超过30个字,不要用表情符号。' }
|
|
53
53
|
bubble = [ordered]@{ opacity = 0.15 }
|
|
54
54
|
voice = [ordered]@{ silenceGate = 1200; boost = 1; engine = 'cloud'; localConf = 0.55 }
|
|
55
|
-
tts = [ordered]@{ engine = 'edge'; voice = 'zh-CN-XiaoxiaoNeural'; rate = '+12%'; pitch = '+10Hz' }
|
|
55
|
+
tts = [ordered]@{ engine = 'edge'; voice = 'zh-CN-XiaoxiaoNeural'; rate = '+12%'; pitch = '+10Hz'; voiceEn = 'en-US-AnaNeural'; rateEn = '+8%'; pitchEn = '+15Hz' }
|
|
56
56
|
}
|
|
57
57
|
$script:cfg = $def
|
|
58
58
|
try {
|
|
@@ -60,9 +60,9 @@ function Load-Config {
|
|
|
60
60
|
[IO.File]::WriteAllText($ConfigPath, ($def | ConvertTo-Json -Depth 5), (New-Object Text.UTF8Encoding($false)))
|
|
61
61
|
}
|
|
62
62
|
$j = [IO.File]::ReadAllText($ConfigPath, [Text.Encoding]::UTF8) | ConvertFrom-Json
|
|
63
|
-
if ($j.persona) { foreach ($k in @('name','dad','mom','extra','birthday','birthplace','likes','dislikes','ownerBirthday')) { $v = $j.persona.$k; if ($v) { $script:cfg.persona[$k] = [string]$v } } }
|
|
63
|
+
if ($j.persona) { foreach ($k in @('name','nameEn','dad','mom','extra','birthday','birthplace','likes','dislikes','ownerBirthday','lang')) { $v = $j.persona.$k; if ($v) { $script:cfg.persona[$k] = [string]$v } } }
|
|
64
64
|
if ($j.bubble -and $j.bubble.opacity) { $script:cfg.bubble.opacity = [double]$j.bubble.opacity }
|
|
65
|
-
if ($j.tts) { foreach ($k2 in @('engine','voice','rate','pitch')) { $v2 = $j.tts.$k2; if ($v2) { $script:cfg.tts[$k2] = [string]$v2 } } }
|
|
65
|
+
if ($j.tts) { foreach ($k2 in @('engine','voice','rate','pitch','voiceEn','rateEn','pitchEn')) { $v2 = $j.tts.$k2; if ($v2) { $script:cfg.tts[$k2] = [string]$v2 } } }
|
|
66
66
|
if ($j.voice) {
|
|
67
67
|
if ($j.voice.silenceGate) { $script:cfg.voice.silenceGate = [int]$j.voice.silenceGate }
|
|
68
68
|
if ($j.voice.boost) { $script:cfg.voice.boost = [int]$j.voice.boost }
|
|
@@ -71,7 +71,25 @@ function Load-Config {
|
|
|
71
71
|
}
|
|
72
72
|
Load-Config
|
|
73
73
|
$script:ttsEngine = $script:cfg.tts.engine
|
|
74
|
+
# 启动自检:edge 音色需要 Python + edge-tts,缺了就退回本机语音并提示
|
|
75
|
+
$script:ttsWarn = $false
|
|
76
|
+
if ($script:ttsEngine -eq 'edge') {
|
|
77
|
+
$edgeOk = $false
|
|
78
|
+
try {
|
|
79
|
+
$null = & python -m edge_tts --version 2>$null
|
|
80
|
+
if ($LASTEXITCODE -eq 0) { $edgeOk = $true }
|
|
81
|
+
} catch { }
|
|
82
|
+
if (-not $edgeOk) {
|
|
83
|
+
$script:ttsEngine = 'sapi'
|
|
84
|
+
$script:ttsWarn = $true
|
|
85
|
+
Write-Err 'TTS-PY-MISSING(已退回本机 SAPI 语音)'
|
|
86
|
+
}
|
|
87
|
+
}
|
|
74
88
|
$script:ttsVoice = $script:cfg.tts.voice
|
|
89
|
+
$script:ttsVoiceEn = $script:cfg.tts.voiceEn
|
|
90
|
+
$script:ttsRateEn = $script:cfg.tts.rateEn
|
|
91
|
+
$script:ttsPitchEn = $script:cfg.tts.pitchEn
|
|
92
|
+
$script:langMode = $script:cfg.persona.lang
|
|
75
93
|
$script:ttsRate = $script:cfg.tts.rate
|
|
76
94
|
$script:ttsPitch = $script:cfg.tts.pitch
|
|
77
95
|
$PidPath = Join-Path $DataDir 'pet.pid'
|
|
@@ -580,6 +598,26 @@ $miTalk = $menu.Items.Add('开始听我说(麦克风)')
|
|
|
580
598
|
$miVoice = $menu.Items.Add('语音朗读:开')
|
|
581
599
|
$miVoiceSet = New-Object System.Windows.Forms.ToolStripMenuItem('换个声音')
|
|
582
600
|
$menu.Items.Add($miVoiceSet) | Out-Null
|
|
601
|
+
$miLang = New-Object System.Windows.Forms.ToolStripMenuItem('语言模式')
|
|
602
|
+
$langChoices = @(
|
|
603
|
+
@('zh', '中文'),
|
|
604
|
+
@('en', 'English'),
|
|
605
|
+
@('auto', '自动模式')
|
|
606
|
+
)
|
|
607
|
+
$script:langItemMap = @{}
|
|
608
|
+
foreach ($lc in $langChoices) {
|
|
609
|
+
$itL = New-Object System.Windows.Forms.ToolStripMenuItem($lc[1])
|
|
610
|
+
$itL.Tag = $lc[0]
|
|
611
|
+
$itL.CheckOnClick = $false
|
|
612
|
+
$itL.Checked = ([string]$script:langMode -eq [string]$lc[0])
|
|
613
|
+
$itL.Add_Click({
|
|
614
|
+
param($s, $e)
|
|
615
|
+
Set-LangMode ([string]$s.Tag) ([string]$s.Text)
|
|
616
|
+
})
|
|
617
|
+
$miLang.DropDownItems.Add($itL) | Out-Null
|
|
618
|
+
$script:langItemMap[[string]$lc[0]] = $itL
|
|
619
|
+
}
|
|
620
|
+
$menu.Items.Add($miLang) | Out-Null
|
|
583
621
|
$voiceChoices = @(
|
|
584
622
|
@('zh-CN-XiaoyiNeural', '活泼少女·晓伊'),
|
|
585
623
|
@('zh-CN-XiaoxiaoNeural', '温柔姐姐·晓晓'),
|
|
@@ -588,9 +626,12 @@ $voiceChoices = @(
|
|
|
588
626
|
@('zh-CN-YunxiNeural', '阳光男声·云希'),
|
|
589
627
|
@('zh-CN-YunxiaNeural', '可爱男声·云夏')
|
|
590
628
|
)
|
|
629
|
+
$script:voiceItemMap = @{}
|
|
591
630
|
foreach ($vc in $voiceChoices) {
|
|
592
631
|
$it = New-Object System.Windows.Forms.ToolStripMenuItem(($vc[1] + '(' + $vc[0] + ')'))
|
|
593
632
|
$it.Tag = $vc[0]
|
|
633
|
+
$it.CheckOnClick = $false
|
|
634
|
+
$it.Checked = ([string]$script:ttsVoice -eq [string]$vc[0])
|
|
594
635
|
$it.Add_Click({
|
|
595
636
|
param($s, $e)
|
|
596
637
|
$script:voicePickTag = [string]$s.Tag
|
|
@@ -598,6 +639,7 @@ foreach ($vc in $voiceChoices) {
|
|
|
598
639
|
Set-TtsVoice $script:voicePickTag $script:voicePickLabel
|
|
599
640
|
})
|
|
600
641
|
$miVoiceSet.DropDownItems.Add($it) | Out-Null
|
|
642
|
+
$script:voiceItemMap[[string]$vc[0]] = $it
|
|
601
643
|
}
|
|
602
644
|
$miWake = $menu.Items.Add('语音待命:开')
|
|
603
645
|
$miChat = $menu.Items.Add('语音聊天:开')
|
|
@@ -868,6 +910,33 @@ function Show-ReminderList {
|
|
|
868
910
|
}
|
|
869
911
|
|
|
870
912
|
# 切换朗读音色(立即生效并写入 config.json)
|
|
913
|
+
# 切换中英文聊天模式(立即生效并写入 config.json)
|
|
914
|
+
function Set-LangMode([string]$mode, [string]$label) {
|
|
915
|
+
try {
|
|
916
|
+
$script:langMode = $mode
|
|
917
|
+
try {
|
|
918
|
+
$cfg = Join-Path $DataDir 'config.json'
|
|
919
|
+
if (Test-Path $cfg) {
|
|
920
|
+
$j = [IO.File]::ReadAllText($cfg, [Text.Encoding]::UTF8) | ConvertFrom-Json
|
|
921
|
+
if (-not $j.persona) { $j | Add-Member -NotePropertyName persona -NotePropertyValue ([ordered]@{ lang = $mode }) }
|
|
922
|
+
elseif ($j.persona.PSObject.Properties.Name -notcontains 'lang') { $j.persona | Add-Member -NotePropertyName lang -NotePropertyValue $mode }
|
|
923
|
+
else { $j.persona.lang = $mode }
|
|
924
|
+
[IO.File]::WriteAllText($cfg, ($j | ConvertTo-Json -Depth 6), (New-Object Text.UTF8Encoding($false)))
|
|
925
|
+
}
|
|
926
|
+
} catch { }
|
|
927
|
+
# 更新子菜单的勾选状态(同时只勾一个)
|
|
928
|
+
try {
|
|
929
|
+
if ($script:langItemMap) {
|
|
930
|
+
foreach ($k in @($script:langItemMap.Keys)) { $script:langItemMap[$k].Checked = ([string]$k -eq [string]$mode) }
|
|
931
|
+
$script:langItemsFresh = $true
|
|
932
|
+
}
|
|
933
|
+
} catch { }
|
|
934
|
+
if ($mode -eq 'en') { Show-Bubble 'Language: English' }
|
|
935
|
+
elseif ($mode -eq 'auto') { Show-Bubble '语言:自动模式' }
|
|
936
|
+
else { Show-Bubble ('语言:' + $label) }
|
|
937
|
+
} catch { Write-Err 'LANG-SET-ERR' }
|
|
938
|
+
}
|
|
939
|
+
|
|
871
940
|
function Set-TtsVoice([string]$voice, [string]$label) {
|
|
872
941
|
try {
|
|
873
942
|
$script:ttsVoice = $voice
|
|
@@ -881,6 +950,11 @@ function Set-TtsVoice([string]$voice, [string]$label) {
|
|
|
881
950
|
}
|
|
882
951
|
} catch { }
|
|
883
952
|
Show-Bubble ('声音换成:' + $label)
|
|
953
|
+
try {
|
|
954
|
+
if ($script:voiceItemMap) {
|
|
955
|
+
foreach ($k in @($script:voiceItemMap.Keys)) { $script:voiceItemMap[$k].Checked = ([string]$k -eq [string]$voice) }
|
|
956
|
+
}
|
|
957
|
+
} catch { }
|
|
884
958
|
Speak-Text ('主人你好呀,我是大肥鱼,现在用的是' + $label + '的声音')
|
|
885
959
|
} catch { Write-Err 'VOICE-SET-ERR' }
|
|
886
960
|
}
|
|
@@ -1029,33 +1103,7 @@ function Show-Bubble([string]$text) {
|
|
|
1029
1103
|
} catch { Write-Err 'BUBBLE-ERR' }
|
|
1030
1104
|
}
|
|
1031
1105
|
|
|
1032
|
-
function Speak-
|
|
1033
|
-
if (-not $script:speakOn) { return }
|
|
1034
|
-
$talkFile = Join-Path $DataDir 'asr.talking'
|
|
1035
|
-
try { [IO.File]::WriteAllText($talkFile, '1') } catch { }
|
|
1036
|
-
# 云语音(Edge 免费音色)优先;失败退回本机 SAPI
|
|
1037
|
-
if ($script:ttsEngine -eq 'edge') {
|
|
1038
|
-
try {
|
|
1039
|
-
$mp3 = Join-Path $env:TEMP ('fish-tts-' + [guid]::NewGuid().ToString('N') + '.mp3')
|
|
1040
|
-
$eargs = @('-m', 'edge_tts', '--voice', $script:ttsVoice, ('--rate=' + $script:ttsRate), ('--pitch=' + $script:ttsPitch), '--text', $text, '--write-media', $mp3)
|
|
1041
|
-
& python @eargs 2>$null | Out-Null
|
|
1042
|
-
if ((Test-Path $mp3) -and (Get-Item $mp3).Length -gt 2000) {
|
|
1043
|
-
if ($null -eq $script:mp) {
|
|
1044
|
-
Add-Type -AssemblyName PresentationCore
|
|
1045
|
-
$script:mp = New-Object System.Windows.Media.MediaPlayer
|
|
1046
|
-
$script:mp.Add_MediaEnded({
|
|
1047
|
-
try { Remove-Item -LiteralPath (Join-Path $DataDir 'asr.talking') -Force -ErrorAction SilentlyContinue } catch { }
|
|
1048
|
-
try { [IO.File]::WriteAllText((Join-Path $DataDir 'asr.pause'), (Get-Date).ToString('o')) } catch { }
|
|
1049
|
-
})
|
|
1050
|
-
}
|
|
1051
|
-
$script:mp.Open([uri]$mp3)
|
|
1052
|
-
$script:mp.Play()
|
|
1053
|
-
Write-Err ('TTS-EDGE len=' + $text.Length + ' file=' + (Get-Item $mp3).Length)
|
|
1054
|
-
try { Get-ChildItem $env:TEMP -Filter 'fish-tts-*.mp3' -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -lt (Get-Date).AddMinutes(-10) } | Remove-Item -Force -ErrorAction SilentlyContinue } catch { }
|
|
1055
|
-
return
|
|
1056
|
-
}
|
|
1057
|
-
} catch { Write-Err ('TTS-EDGE-ERR ' + $_.Exception.Message) }
|
|
1058
|
-
}
|
|
1106
|
+
function Speak-Sapi([string]$text) {
|
|
1059
1107
|
try {
|
|
1060
1108
|
if ($null -eq $script:synth) {
|
|
1061
1109
|
Add-Type -AssemblyName System.Speech
|
|
@@ -1084,6 +1132,219 @@ function Speak-Text([string]$text) {
|
|
|
1084
1132
|
} catch { Write-Err ('TTS-ERR ' + $_.Exception.Message) }
|
|
1085
1133
|
}
|
|
1086
1134
|
|
|
1135
|
+
function Play-TtsFile([string]$mp3, [string]$voice) {
|
|
1136
|
+
try {
|
|
1137
|
+
if ($null -eq $script:mp) {
|
|
1138
|
+
Add-Type -AssemblyName PresentationCore
|
|
1139
|
+
$script:mp = New-Object System.Windows.Media.MediaPlayer
|
|
1140
|
+
$script:mp.Add_MediaEnded({ Complete-TtsSegment })
|
|
1141
|
+
$script:mp.Add_MediaOpened({
|
|
1142
|
+
try { if ($script:mp.NaturalDuration.HasTimeSpan) { $script:speakEndAt = (Get-Date).AddSeconds($script:mp.NaturalDuration.TimeSpan.TotalSeconds + 0.8) } } catch { }
|
|
1143
|
+
})
|
|
1144
|
+
}
|
|
1145
|
+
$script:mp.Open([uri]$mp3)
|
|
1146
|
+
$script:mp.Play()
|
|
1147
|
+
Write-Err ('TTS-EDGE voice=' + $voice + ' file=' + (Get-Item $mp3).Length)
|
|
1148
|
+
try { Get-ChildItem $env:TEMP -Filter 'fish-tts-*.mp3' -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -lt (Get-Date).AddMinutes(-10) } | Remove-Item -Force -ErrorAction SilentlyContinue } catch { }
|
|
1149
|
+
} catch { Write-Err ('TTS-PLAY-ERR ' + $_.Exception.Message) }
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
# 把回复拆成 1~3 段,边合成边播,缩短"开口等待"
|
|
1153
|
+
function Split-TtsSegments([string]$text) {
|
|
1154
|
+
if (-not $text) { return @() }
|
|
1155
|
+
$parts = @($text -split '(?<=[。!?!?;;…\.\r\n])' | Where-Object { $_.Trim() })
|
|
1156
|
+
if ($parts.Count -le 1) { return @($text) }
|
|
1157
|
+
# 第一段尽量短(先开口),后面合并成最多 2 段
|
|
1158
|
+
$first = $parts[0].Trim()
|
|
1159
|
+
$rest = ''
|
|
1160
|
+
for ($i = 1; $i -lt $parts.Count; $i++) { $rest = $rest + $parts[$i] }
|
|
1161
|
+
$rest = $rest.Trim()
|
|
1162
|
+
if (-not $rest) { return @($text) }
|
|
1163
|
+
if ($rest.Length -le 26) { return ,@($first, $rest) }
|
|
1164
|
+
# 剩余拆成两半
|
|
1165
|
+
$mid = [Math]::Floor($rest.Length / 2)
|
|
1166
|
+
$cut = $rest.LastIndexOfAny([char[]]@(',', ',', ';', '、', ' ', '。', '.'), $mid)
|
|
1167
|
+
if ($cut -lt 8) { $cut = $mid }
|
|
1168
|
+
$a = $rest.Substring(0, $cut + 1).Trim()
|
|
1169
|
+
$b = $rest.Substring($cut + 1).Trim()
|
|
1170
|
+
if (-not $b) { return ,@($first, $a) }
|
|
1171
|
+
return ,@($first, $a, $b)
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function Start-TtsPipeline([string]$text, [string]$voice, [string]$rate, [string]$pitch) {
|
|
1175
|
+
$segs = Split-TtsSegments $text
|
|
1176
|
+
$script:ttsSegs = @($segs)
|
|
1177
|
+
$script:ttsSegVoice = $voice
|
|
1178
|
+
$script:ttsSegRate = $rate
|
|
1179
|
+
$script:ttsSegPitch = $pitch
|
|
1180
|
+
$script:ttsSegFiles = @()
|
|
1181
|
+
$script:ttsSegDone = @()
|
|
1182
|
+
$script:ttsSegPlayed = @()
|
|
1183
|
+
$script:ttsSegReq = @()
|
|
1184
|
+
for ($i = 0; $i -lt $script:ttsSegs.Count; $i++) {
|
|
1185
|
+
$script:ttsSegFiles += (Join-Path $env:TEMP ('fish-tts-' + [guid]::NewGuid().ToString('N') + '.mp3'))
|
|
1186
|
+
$script:ttsSegDone += $false
|
|
1187
|
+
$script:ttsSegPlayed += $false
|
|
1188
|
+
$script:ttsSegReq += ''
|
|
1189
|
+
}
|
|
1190
|
+
$script:ttsSegPlaying = -1
|
|
1191
|
+
Write-Err ('TTS-PIPE segs=' + $script:ttsSegs.Count + ' len=' + $text.Length)
|
|
1192
|
+
$script:ttsTimer.Start()
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function Request-TtsSegment([int]$idx) {
|
|
1196
|
+
if ($idx -lt 0 -or $idx -ge $script:ttsSegs.Count) { return }
|
|
1197
|
+
if ($script:ttsSegReq[$idx]) { return }
|
|
1198
|
+
try {
|
|
1199
|
+
$reqId = '{0:D2}{1}' -f $idx, ([guid]::NewGuid().ToString('N').Substring(0, 8))
|
|
1200
|
+
$reqFile = Join-Path $env:TEMP ('fish-tts-req-' + $reqId + '.json')
|
|
1201
|
+
$reqObj = @{
|
|
1202
|
+
id = $reqId
|
|
1203
|
+
text = $script:ttsSegs[$idx]
|
|
1204
|
+
voice = $script:ttsSegVoice
|
|
1205
|
+
rate = $script:ttsSegRate
|
|
1206
|
+
pitch = $script:ttsSegPitch
|
|
1207
|
+
out = $script:ttsSegFiles[$idx]
|
|
1208
|
+
status = $script:ttsStatusFile
|
|
1209
|
+
} | ConvertTo-Json -Compress
|
|
1210
|
+
[IO.File]::WriteAllText($reqFile, $reqObj, (New-Object Text.UTF8Encoding($false)))
|
|
1211
|
+
$script:ttsProc.StandardInput.WriteLine('@' + $reqFile)
|
|
1212
|
+
$script:ttsProc.StandardInput.Flush()
|
|
1213
|
+
$script:ttsSegReq[$idx] = $reqId
|
|
1214
|
+
Write-Err ('TTS-REQ idx=' + $idx + ' id=' + $reqId)
|
|
1215
|
+
} catch { Write-Err ('TTS-REQ-ERR ' + $_.Exception.Message) }
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function Complete-TtsSegment {
|
|
1219
|
+
try {
|
|
1220
|
+
$cur = $script:ttsSegPlaying
|
|
1221
|
+
if ($cur -ge 0 -and $cur -lt $script:ttsSegPlayed.Count) { $script:ttsSegPlayed[$cur] = $true }
|
|
1222
|
+
$script:ttsSegPlaying = -1
|
|
1223
|
+
$script:speakEndAt = (Get-Date)
|
|
1224
|
+
} catch { }
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
# 启动常驻 TTS 进程(复用连接,比每次启动 python 快一倍)
|
|
1228
|
+
function Start-TtsWorker {
|
|
1229
|
+
try {
|
|
1230
|
+
if ($script:ttsProc -and -not $script:ttsProc.HasExited) { return $true }
|
|
1231
|
+
$workerPath = Join-Path (Split-Path -Parent $PSCommandPath) 'tts_worker.py'
|
|
1232
|
+
if (-not (Test-Path $workerPath)) { Write-Err 'TTS-WORKER-MISSING'; return $false }
|
|
1233
|
+
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
|
1234
|
+
$psi.FileName = 'python'
|
|
1235
|
+
$psi.Arguments = '"' + $workerPath + '"'
|
|
1236
|
+
$psi.UseShellExecute = $false
|
|
1237
|
+
$psi.RedirectStandardInput = $true
|
|
1238
|
+
$psi.CreateNoWindow = $true
|
|
1239
|
+
$script:ttsProc = [System.Diagnostics.Process]::Start($psi)
|
|
1240
|
+
Start-Sleep -Milliseconds 350
|
|
1241
|
+
if ($script:ttsProc.HasExited) { Write-Err 'TTS-WORKER-DIED'; return $false }
|
|
1242
|
+
$script:ttsStatusFile = Join-Path $env:TEMP 'fish-tts-status.txt'
|
|
1243
|
+
Write-Err ('TTS-WORKER-START pid=' + $script:ttsProc.Id)
|
|
1244
|
+
return $true
|
|
1245
|
+
} catch { Write-Err ('TTS-WORKER-ERR ' + $_.Exception.Message); return $false }
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function Speak-Text([string]$text) {
|
|
1249
|
+
if (-not $script:speakOn) { return }
|
|
1250
|
+
$talkFile = Join-Path $DataDir 'asr.talking'
|
|
1251
|
+
try { [IO.File]::WriteAllText($talkFile, '1') } catch { }
|
|
1252
|
+
if ($script:ttsEngine -eq 'edge') {
|
|
1253
|
+
try {
|
|
1254
|
+
$spkLang = Get-TextLang $text
|
|
1255
|
+
if ($spkLang -eq 'en') { $vUse = $script:ttsVoiceEn; $rUse = $script:ttsRateEn; $pUse = $script:ttsPitchEn } else { $vUse = $script:ttsVoice; $rUse = $script:ttsRate; $pUse = $script:ttsPitch }
|
|
1256
|
+
if (Start-TtsWorker) {
|
|
1257
|
+
$script:ttsMode = 'worker'
|
|
1258
|
+
$script:ttsVoiceUsed = $vUse
|
|
1259
|
+
$script:ttsText = $text
|
|
1260
|
+
Start-TtsPipeline $text $vUse $rUse $pUse
|
|
1261
|
+
return
|
|
1262
|
+
}
|
|
1263
|
+
# 常驻进程起不来:退回后台线程整段合成
|
|
1264
|
+
if ($null -eq $script:ttsPool) {
|
|
1265
|
+
$script:ttsPool = [runspacefactory]::CreateRunspacePool(1, 2)
|
|
1266
|
+
$script:ttsPool.Open()
|
|
1267
|
+
}
|
|
1268
|
+
$mp3 = Join-Path $env:TEMP ('fish-tts-' + [guid]::NewGuid().ToString('N') + '.mp3')
|
|
1269
|
+
$ps = [PowerShell]::Create()
|
|
1270
|
+
$ps.RunspacePool = $script:ttsPool
|
|
1271
|
+
[void]$ps.AddScript({
|
|
1272
|
+
param($exe, $voice, $rate, $pitch, $txt, $out)
|
|
1273
|
+
& $exe -m edge_tts --voice $voice ('--rate=' + $rate) ('--pitch=' + $pitch) '--text' $txt '--write-media' $out 2>$null | Out-Null
|
|
1274
|
+
}).AddArgument('python').AddArgument($vUse).AddArgument($rUse).AddArgument($pUse).AddArgument($text).AddArgument($mp3)
|
|
1275
|
+
$script:ttsPS = $ps
|
|
1276
|
+
$script:ttsHandle = $ps.BeginInvoke()
|
|
1277
|
+
$script:ttsMode = 'thread'
|
|
1278
|
+
$script:ttsMp3 = $mp3
|
|
1279
|
+
$script:ttsVoiceUsed = $vUse
|
|
1280
|
+
$script:ttsText = $text
|
|
1281
|
+
$script:ttsTimer.Start()
|
|
1282
|
+
Write-Err ('TTS-EDGE-START len=' + $text.Length + ' voice=' + $vUse + ' mode=thread')
|
|
1283
|
+
return
|
|
1284
|
+
} catch { Write-Err ('TTS-EDGE-ERR ' + $_.Exception.Message) }
|
|
1285
|
+
}
|
|
1286
|
+
Speak-Sapi $text
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
# 流水线调度:边合成边播
|
|
1290
|
+
$script:ttsTimer = New-Object System.Windows.Forms.Timer
|
|
1291
|
+
$script:ttsTimer.Interval = 90
|
|
1292
|
+
$script:ttsTimer.Add_Tick({
|
|
1293
|
+
# ---- 常驻进程 + 分段流水线 ----
|
|
1294
|
+
if ($script:ttsMode -eq 'worker' -and $script:ttsSegs.Count -gt 0) {
|
|
1295
|
+
# 1) 扫状态文件,标记完成的分段
|
|
1296
|
+
for ($i = 0; $i -lt $script:ttsSegs.Count; $i++) {
|
|
1297
|
+
if ($script:ttsSegDone[$i] -or -not $script:ttsSegReq[$i]) { continue }
|
|
1298
|
+
$rid = $script:ttsSegReq[$i]
|
|
1299
|
+
try {
|
|
1300
|
+
$hit = Get-Content $script:ttsStatusFile -ErrorAction SilentlyContinue | Where-Object { $_ -match ('^' + $rid + ' ') } | Select-Object -Last 1
|
|
1301
|
+
} catch { $hit = $null }
|
|
1302
|
+
if ($hit) {
|
|
1303
|
+
if ($hit -like ('*' + $rid + ' OK*')) { $script:ttsSegDone[$i] = $true }
|
|
1304
|
+
else { Write-Err ('TTS-SEG-FAIL idx=' + $i); $script:ttsSegDone[$i] = $true }
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
# 2) 没有在播 → 找下一段
|
|
1308
|
+
if ($script:ttsSegPlaying -lt 0) {
|
|
1309
|
+
$next = -1
|
|
1310
|
+
for ($i = 0; $i -lt $script:ttsSegs.Count; $i++) { if (-not $script:ttsSegPlayed[$i]) { $next = $i; break } }
|
|
1311
|
+
if ($next -lt 0) {
|
|
1312
|
+
$script:ttsTimer.Stop()
|
|
1313
|
+
$script:ttsSegs = @()
|
|
1314
|
+
return
|
|
1315
|
+
}
|
|
1316
|
+
if ($script:ttsSegDone[$next]) {
|
|
1317
|
+
$file = $script:ttsSegFiles[$next]
|
|
1318
|
+
if ((Test-Path $file) -and (Get-Item $file).Length -gt 800) {
|
|
1319
|
+
$script:ttsSegPlaying = $next
|
|
1320
|
+
Play-TtsFile $file $script:ttsVoiceUsed
|
|
1321
|
+
} else {
|
|
1322
|
+
$script:ttsSegPlayed[$next] = $true
|
|
1323
|
+
}
|
|
1324
|
+
} elseif (-not $script:ttsSegReq[$next]) {
|
|
1325
|
+
Request-TtsSegment $next
|
|
1326
|
+
}
|
|
1327
|
+
} else {
|
|
1328
|
+
# 3) 正在播:预取下一段
|
|
1329
|
+
$nx = $script:ttsSegPlaying + 1
|
|
1330
|
+
if ($nx -lt $script:ttsSegs.Count -and -not $script:ttsSegDone[$nx] -and -not $script:ttsSegReq[$nx]) { Request-TtsSegment $nx }
|
|
1331
|
+
}
|
|
1332
|
+
return
|
|
1333
|
+
}
|
|
1334
|
+
# ---- 后台线程整段模式 ----
|
|
1335
|
+
if ($null -eq $script:ttsHandle) { $script:ttsTimer.Stop(); return }
|
|
1336
|
+
if (-not $script:ttsHandle.IsCompleted) { return }
|
|
1337
|
+
$script:ttsTimer.Stop()
|
|
1338
|
+
$ps = $script:ttsPS; $h = $script:ttsHandle; $mp3 = $script:ttsMp3; $v = $script:ttsVoiceUsed; $tx = $script:ttsText
|
|
1339
|
+
$script:ttsHandle = $null; $script:ttsPS = $null
|
|
1340
|
+
try { [void]$ps.EndInvoke($h) } catch { Write-Err ('TTS-JOIN-ERR ' + $_.Exception.Message) }
|
|
1341
|
+
try { $ps.Dispose() } catch { }
|
|
1342
|
+
try {
|
|
1343
|
+
if ((Test-Path $mp3) -and (Get-Item $mp3).Length -gt 2000) { Play-TtsFile $mp3 $v }
|
|
1344
|
+
else { Speak-Sapi $tx }
|
|
1345
|
+
} catch { Write-Err ('TTS-DONE-ERR ' + $_.Exception.Message) }
|
|
1346
|
+
})
|
|
1347
|
+
|
|
1087
1348
|
# ---------- 麦克风:离线中文识别 + 唤醒词 ----------
|
|
1088
1349
|
$script:rec = $null
|
|
1089
1350
|
$script:gWake = $null
|
|
@@ -1228,6 +1489,22 @@ function Fix-AsrText([string]$s) {
|
|
|
1228
1489
|
foreach ($k in $map.Keys) { $s = $s.Replace($k, $map[$k]) }
|
|
1229
1490
|
return $s
|
|
1230
1491
|
}
|
|
1492
|
+
# 判断一段文字主要是什么语言(zh / en)
|
|
1493
|
+
function Get-TextLang([string]$t) {
|
|
1494
|
+
if (-not $t) { return 'zh' }
|
|
1495
|
+
$cjk = ([regex]::Matches($t, '[\u4e00-\u9fff]')).Count
|
|
1496
|
+
$lat = ([regex]::Matches($t, '[A-Za-z]')).Count
|
|
1497
|
+
if ($lat -gt ($cjk * 2) -and $lat -ge 4) { return 'en' }
|
|
1498
|
+
return 'zh'
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
# 本次回复该用什么语言:中文 / English / 自动模式
|
|
1502
|
+
function Get-ReplyLang([string]$userText) {
|
|
1503
|
+
if ($script:langMode -eq 'en') { return 'en' }
|
|
1504
|
+
if ($script:langMode -eq 'zh') { return 'zh' }
|
|
1505
|
+
return (Get-TextLang $userText)
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1231
1508
|
function Start-Chat([string]$text) {
|
|
1232
1509
|
Write-Err ('CHAT-START busy=' + $script:chatBusy + ' text=' + $text)
|
|
1233
1510
|
$text = Fix-AsrText $text
|
|
@@ -1242,26 +1519,58 @@ function Start-Chat([string]$text) {
|
|
|
1242
1519
|
$script:lastUser = $text
|
|
1243
1520
|
Show-Bubble '…'
|
|
1244
1521
|
$pn = $script:cfg.persona
|
|
1245
|
-
$
|
|
1522
|
+
$replyLang = Get-ReplyLang $text
|
|
1523
|
+
if ($replyLang -eq 'en') {
|
|
1524
|
+
$sys = 'YOU MUST REPLY IN ENGLISH ONLY, NO MATTER WHAT. You are a round, chubby blue fish living on the master''s Windows desktop. Your name is "' + $pn.nameEn + '" (in Chinese it is written 大肥鱼, said as Da Fei Yu). Your dad is Guangguang, your mom is Lili, your birthday is October 30 2026, you were born in Beihai, Guangxi, China. Answer questions about family/birthday/birthplace with these facts and never invent them. In English your name is always this one, never any other name; if earlier messages called you something different, correct them politely.'
|
|
1525
|
+
$sys = $sys + ' Reply ONLY in English, max 20 words, playful and a little tsundere, call the user "master", no emoji.'
|
|
1526
|
+
if ($pn.likes) { $sys = $sys + ' You like: ' + $pn.likes + ' .' }
|
|
1527
|
+
if ($pn.dislikes) { $sys = $sys + ' You dislike: ' + $pn.dislikes + ' .' }
|
|
1528
|
+
} else {
|
|
1529
|
+
$sys = '你是主人桌面上的一条圆滚滚的蓝色小鱼,名字叫' + $pn.name + ',爸爸叫' + $pn.dad + ',妈妈叫' + $pn.mom + ',生日是' + $pn.birthday + ',出生在' + $pn.birthplace + '。有人问起家人、生日、出生地就照这个答,不要瞎编。' + $pn.extra
|
|
1530
|
+
if ($pn.likes) { $sys = $sys + ' 你喜欢:' + $pn.likes + '。' }
|
|
1531
|
+
if ($pn.dislikes) { $sys = $sys + ' 你讨厌:' + $pn.dislikes + '。' }
|
|
1532
|
+
}
|
|
1246
1533
|
$moodMap = @{ 'happy' = '心情很好'; 'sad' = '有点委屈'; 'hungry' = '肚子有点饿'; 'lonely' = '有点被冷落'; 'normal' = '心情平静' }
|
|
1534
|
+
$moodMapEn = @{ 'happy' = 'happy'; 'sad' = 'a bit sad'; 'hungry' = 'a bit hungry'; 'lonely' = 'feeling ignored'; 'normal' = 'calm' }
|
|
1247
1535
|
$moodDesc = $moodMap[[string]$script:mood]
|
|
1248
1536
|
if (-not $moodDesc) { $moodDesc = '心情平静' }
|
|
1249
|
-
|
|
1250
|
-
if ($
|
|
1537
|
+
$moodDescEn = $moodMapEn[[string]$script:mood]
|
|
1538
|
+
if (-not $moodDescEn) { $moodDescEn = 'calm' }
|
|
1251
1539
|
$noteArr = @(Load-Notes)
|
|
1252
1540
|
if ($noteArr.Count -gt 0) {
|
|
1253
1541
|
$noteText = (@($noteArr | Select-Object -Last 5 | ForEach-Object { [string]$_.text }) -join ';')
|
|
1254
|
-
if ($noteText) {
|
|
1542
|
+
if ($noteText) {
|
|
1543
|
+
if ($replyLang -eq 'en') { $sys = $sys + ' Things the master asked you to remember: ' + $noteText + '.' }
|
|
1544
|
+
else { $sys = $sys + ' 主人交代过要你记住的事:' + $noteText + '。' }
|
|
1545
|
+
}
|
|
1255
1546
|
}
|
|
1256
1547
|
try {
|
|
1257
|
-
if ($((Get-Date).ToString('MM-dd')) -eq $script:birthdayMMDD) {
|
|
1548
|
+
if ($((Get-Date).ToString('MM-dd')) -eq $script:birthdayMMDD) {
|
|
1549
|
+
if ($replyLang -eq 'en') { $sys = $sys + ' Note: today is your own birthday, you may ask the master for a treat.' }
|
|
1550
|
+
else { $sys = $sys + ' 提醒你:今天是你自己的生日,可以主动跟主人讨点好吃的。' }
|
|
1551
|
+
}
|
|
1258
1552
|
} catch { }
|
|
1259
|
-
|
|
1260
|
-
|
|
1553
|
+
if ($replyLang -eq 'en') {
|
|
1554
|
+
$sys = $sys + ' Right now you feel ' + $moodDescEn + ', hunger about ' + [int]$script:hunger + ', closeness to the master about ' + [int]$script:intimacy + '. Let that show naturally, never state the numbers.'
|
|
1555
|
+
$sys = $sys + "`r`n`r`nIMPORTANT: Answer ONLY in English. Never write Chinese characters - not even your own name. Use your English name and write family names in pinyin (Guangguang, Lili). Everything you output must be English only."
|
|
1556
|
+
} else {
|
|
1557
|
+
$sys = $sys + ' 你现在的状态:' + $moodDesc + ',饥饿值约' + [int]$script:hunger + ',和主人的亲密度约' + [int]$script:intimacy + '。说话要自然带出这种状态,但不要报具体数字。'
|
|
1558
|
+
}
|
|
1559
|
+
if ($replyLang -eq 'en') { Write-Err ('SYS-EN[' + $sys.Length + ']: ' + ($sys -replace "[\r\n]+", ' | ')) }
|
|
1261
1560
|
$msgs = @(@{ role = 'system'; content = $sys })
|
|
1262
|
-
|
|
1561
|
+
if ($replyLang -eq 'en') {
|
|
1562
|
+
# 英文模式只带英文历史,避免被中文上下文带回去
|
|
1563
|
+
$histEn = @($script:chatHistory | Where-Object { (Get-TextLang ([string]$_.content)) -eq 'en' -and ([string]$_.content) -notmatch 'Fatty Fish' })
|
|
1564
|
+
if ($histEn.Count -gt 4) { $histEn = $histEn[($histEn.Count - 4)..($histEn.Count - 1)] }
|
|
1565
|
+
foreach ($m in $histEn) { $msgs += $m }
|
|
1566
|
+
} else {
|
|
1567
|
+
# 中文模式只带中文历史,避免被上一段英文带跑
|
|
1568
|
+
$histZh = @($script:chatHistory | Where-Object { (Get-TextLang ([string]$_.content)) -eq 'zh' })
|
|
1569
|
+
if ($histZh.Count -gt 10) { $histZh = $histZh[($histZh.Count - 10)..($histZh.Count - 1)] }
|
|
1570
|
+
foreach ($m in $histZh) { $msgs += $m }
|
|
1571
|
+
}
|
|
1263
1572
|
$msgs += @{ role = 'user'; content = $text }
|
|
1264
|
-
$body = @{ model = 'deepseek-flash'; messages = $msgs; max_tokens =
|
|
1573
|
+
$body = @{ model = 'deepseek-flash'; messages = $msgs; max_tokens = 160; temperature = 0.9; thinking = @{ type = 'disabled' } } | ConvertTo-Json -Depth 6
|
|
1265
1574
|
if ($null -eq $script:http) {
|
|
1266
1575
|
$script:http = New-Object System.Net.Http.HttpClient
|
|
1267
1576
|
$script:http.Timeout = [TimeSpan]::FromSeconds(40)
|
|
@@ -1374,6 +1683,8 @@ $script:menuTimer.Interval = 120
|
|
|
1374
1683
|
$script:menuTimer.Add_Tick({
|
|
1375
1684
|
$script:menuTimer.Stop()
|
|
1376
1685
|
try {
|
|
1686
|
+
try { foreach ($k in @($script:langItemMap.Keys)) { $script:langItemMap[$k].Checked = ([string]$k -eq [string]$script:langMode) } } catch { }
|
|
1687
|
+
try { foreach ($k2 in @($script:voiceItemMap.Keys)) { $script:voiceItemMap[$k2].Checked = ([string]$k2 -eq [string]$script:ttsVoice) } } catch { }
|
|
1377
1688
|
$miPause.Text = if ($script:paused) { '继续游' } else { '停下歇会儿' }
|
|
1378
1689
|
try { $miBalance.Text = Get-BalanceText } catch { }
|
|
1379
1690
|
$menu.Show($script:menuScreenPt)
|
|
@@ -1609,6 +1920,29 @@ $timer.Add_Tick({
|
|
|
1609
1920
|
}
|
|
1610
1921
|
}
|
|
1611
1922
|
|
|
1923
|
+
# 缺 Python/edge-tts 时提示一次
|
|
1924
|
+
if ($script:ttsWarn -and -not $script:ttsWarnShown) {
|
|
1925
|
+
$script:ttsWarnShown = $true
|
|
1926
|
+
Show-Bubble '没装 Python/edge-tts,先用本机声音;装好后能换少女音'
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
# 说完之后再恢复收音(否则会听见自己说话,自己跟自己聊)
|
|
1930
|
+
if ($script:speakEndAt -and (Get-Date) -ge $script:speakEndAt) {
|
|
1931
|
+
try { Remove-Item -LiteralPath (Join-Path $DataDir 'asr.talking') -Force -ErrorAction SilentlyContinue } catch { }
|
|
1932
|
+
try { [IO.File]::WriteAllText((Join-Path $DataDir 'asr.pause'), (Get-Date).ToString('o')) } catch { }
|
|
1933
|
+
$script:speakEndAt = $null
|
|
1934
|
+
}
|
|
1935
|
+
# 看门狗:收音开关卡住(超过 25 秒还没关)就强制恢复,避免鱼彻底听不见
|
|
1936
|
+
$tkFile = Join-Path $DataDir 'asr.talking'
|
|
1937
|
+
if (Test-Path $tkFile) {
|
|
1938
|
+
$tkAge = ((Get-Date) - (Get-Item $tkFile).LastWriteTime).TotalSeconds
|
|
1939
|
+
if ($tkAge -gt 25) {
|
|
1940
|
+
try { Remove-Item -LiteralPath $tkFile -Force -ErrorAction SilentlyContinue } catch { }
|
|
1941
|
+
$script:speakEndAt = $null
|
|
1942
|
+
Write-Err ('TALKING-WATCHDOG age=' + [int]$tkAge)
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1612
1946
|
# 到点的提醒:主动冒泡 + 开口
|
|
1613
1947
|
if ($script:frames % 600 -eq 0) {
|
|
1614
1948
|
try {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""常驻 TTS 工作进程:从 stdin 读 JSON 请求,合成后把结果追加到状态文件。
|
|
2
|
+
请求格式(一行一个 JSON):
|
|
3
|
+
{"id":"abc","text":"要念的话","voice":"zh-CN-XiaoxiaoNeural","rate":"+12%","pitch":"+10Hz","out":"C:/.../x.mp3","status":"C:/.../status.txt"}
|
|
4
|
+
状态文件行格式: <id> OK <out路径> 或 <id> ERR <错误信息>
|
|
5
|
+
"""
|
|
6
|
+
import sys
|
|
7
|
+
import json
|
|
8
|
+
import asyncio
|
|
9
|
+
import traceback
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import edge_tts
|
|
13
|
+
except Exception as exc: # 依赖缺失时直接退出,让主程序回退到 SAPI
|
|
14
|
+
sys.stderr.write("NO_EDGE_TTS: %s\n" % exc)
|
|
15
|
+
sys.exit(3)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def write_status(path, req_id, text):
|
|
19
|
+
try:
|
|
20
|
+
with open(path, "a", encoding="utf-8") as fh:
|
|
21
|
+
fh.write("%s %s\n" % (req_id, text))
|
|
22
|
+
fh.flush()
|
|
23
|
+
except Exception:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def synth(req):
|
|
28
|
+
voice = req.get("voice") or "zh-CN-XiaoxiaoNeural"
|
|
29
|
+
rate = req.get("rate") or "+0%"
|
|
30
|
+
pitch = req.get("pitch") or "+0Hz"
|
|
31
|
+
communicate = edge_tts.Communicate(req["text"], voice, rate=rate, pitch=pitch)
|
|
32
|
+
await communicate.save(req["out"])
|
|
33
|
+
write_status(req["status"], req["id"], "OK " + req["out"])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def main():
|
|
37
|
+
loop = asyncio.get_event_loop()
|
|
38
|
+
while True:
|
|
39
|
+
line = await loop.run_in_executor(None, sys.stdin.readline)
|
|
40
|
+
if not line:
|
|
41
|
+
break
|
|
42
|
+
line = line.strip()
|
|
43
|
+
if not line:
|
|
44
|
+
continue
|
|
45
|
+
# 支持"@文件路径"形式:避免 Windows PowerShell 写 stdin 时的编码问题
|
|
46
|
+
if line.startswith("@"):
|
|
47
|
+
try:
|
|
48
|
+
with open(line[1:], "r", encoding="utf-8") as fh:
|
|
49
|
+
req = json.loads(fh.read())
|
|
50
|
+
except Exception:
|
|
51
|
+
continue
|
|
52
|
+
else:
|
|
53
|
+
try:
|
|
54
|
+
req = json.loads(line)
|
|
55
|
+
except Exception:
|
|
56
|
+
continue
|
|
57
|
+
if req.get("cmd") == "quit":
|
|
58
|
+
break
|
|
59
|
+
try:
|
|
60
|
+
await synth(req)
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
write_status(req["status"], req["id"], "ERR " + str(exc).replace("\n", " ")[:200])
|
|
63
|
+
traceback.print_exc()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
asyncio.run(main())
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-fish-pet",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "Desktop blue fish pet for DeepSeek Harness: per-pixel transparent WinForms window, text chat, and cloud speech-to-text voice conversation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|