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.
@@ -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 = '你说话简短、俏皮、微微傲娇,用中文,称呼用户为"主人",每次回复不超过60个字,不要用表情符号。' }
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-Text([string]$text) {
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
- $sys = '你是主人桌面上的一条圆滚滚的蓝色小鱼,名字叫' + $pn.name + ',爸爸叫' + $pn.dad + ',妈妈叫' + $pn.mom + ',生日是' + $pn.birthday + ',出生在' + $pn.birthplace + '。有人问起家人、生日、出生地就照这个答,不要瞎编。' + $pn.extra
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
- if ($pn.likes) { $sys = $sys + ' 你喜欢:' + $pn.likes + '。' }
1250
- if ($pn.dislikes) { $sys = $sys + ' 你讨厌:' + $pn.dislikes + '。' }
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) { $sys = $sys + ' 主人交代过要你记住的事:' + $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) { $sys = $sys + ' 提醒你:今天是你自己的生日,可以主动跟主人讨点好吃的。' }
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
- $sys = $sys + ' 你现在的状态:' + $moodDesc + ',饥饿值约' + [int]$script:hunger + ',和主人的亲密度约' + [int]$script:intimacy + '。说话要自然带出这种状态,但不要报具体数字。'
1260
- if ($script:memorySummary) { $sys = $sys + ' 你记得之前和主人聊过:' + $script:memorySummary }
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
- foreach ($m in $script:chatHistory) { $msgs += $m }
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 = 300; temperature = 0.9; thinking = @{ type = 'disabled' } } | ConvertTo-Json -Depth 6
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.4",
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",