改進 TTS 過濾頁卡片排版
Docs/ChangeHistory.mdliveAPP/TTSSettingsView.swift
commit 240809e482aff32207ebefe25d8946cd8cd67779Author: Nuclear0709 <agp05432@gmail.com>Date: Wed Nov 11 05:01:56 2026 +0800 改進 TTS 過濾頁卡片排版 diff --git a/Docs/ChangeHistory.md b/Docs/ChangeHistory.md--- a/Docs/ChangeHistory.md+++ b/Docs/ChangeHistory.md@@ -18,6 +18,7 @@ - `AppIcon.appiconset` 原本的 `AppIcon 1.png`/`AppIcon 2.png` 不是不同尺寸,而是與 default 相同 SHA256 的 dark/tinted appearance 變體。已移除兩張重複檔,只保留單張 1024 default;iOS 其餘尺寸由 actool 產生,不再以 legacy 多尺寸重複塞入 catalog。 - GPU 旋轉處理設定頁改為 `AdaptiveSettingsGrid` 卡片:配置、畫布尺寸、方向與縮放三個區塊;寬螢幕並排、窄螢幕單欄,OD/AD 寬高欄位以同一列呈現,減少 iPad 全域捲動。 - 新增 base64 `data:image/...` 圖片來源:聊天頭像/禮物、行內 emoji、贊助橫幅與通知共用本機解碼,不進 URLSession;限制單張 256 KiB、單訊息 768 KiB、4 MP,拒絕 SVG/非 base64/超量,HTTP URL 仍保留給大圖。日誌以長度遮蔽 base64,TTS 與通知使用清洗後文字。+- TTS 過濾頁改為分組卡片:測試與設定/基本清理/排除關鍵字/替換關鍵字;匯入匯出改為圖示按鈕,重複字元選單加上可見標題與說明,並在測試結果為空時明示「會被過濾,不朗讀」。 - ReplayKit 的 `StreamDiagnosticsSnapshot` 現在也會帶 `audioTracks`;主 App 的「Audio Pipeline · 混音輸出」在 ReplayKit 與 ScreenCaptureKit 都能看到 App/Mic 的 AVCon、ring buffer、align 與 RMS。轉換邏輯集中在 `StreamDiagnosticsSnapshot.audioTrackDiagnostics(from:)`,避免兩條路徑各寫一份。 - `[IdleResources]` 新增 `warmIdleBaselineMB`/`deltaWarmIdleMB`:每次進入擷取前記錄最近一次已穩定的 idle footprint,包含 DeviceInfo/Settings/Audio/PiP 等 UI 常駐成本;`coldIdleBaselineMB` 保留作冷啟動對照,避免把 100 MB warm idle 誤算成串流增量。 - 修正 DeviceView 與 FileLogView 的週期 task:不再使用 `.task(id:)` 由布林狀態重啟,改成頁面可見且前景時才啟動單一 `Task`,離頁/背景立即取消;避免隱藏分頁或重複 view 持續每秒取樣、刷新日誌而累積記憶體。診斷 log 的 sampling start/stop 會用來確認沒有重複 task。diff --git a/liveAPP/TTSSettingsView.swift b/liveAPP/TTSSettingsView.swift--- a/liveAPP/TTSSettingsView.swift+++ b/liveAPP/TTSSettingsView.swift@@ -422,101 +422,138 @@ private struct SpeechFilterDocumentPicker: UIViewControllerRepresentable { // 左邊:輸入區 private var inputSection: some View { VStack(alignment: .leading, spacing: 16) {- HStack {- Button("匯入 JSON") {- notice = "請選取 JSON 配置檔案。"- sendlog(title: "TTS匯入", message: "開啟文件選擇器")- importing = true- }- .disabled(isReadingImport)- Button("匯出目前配置") {- do {- let url = try filter.configuration.write(to: documentsDirectory)- exportedURL = url- notice = "已儲存至檔案分享目錄 Documents:\(url.lastPathComponent)"- } catch { notice = error.localizedDescription }+ GroupBox("測試與設定") {+ VStack(alignment: .leading, spacing: 12) {+ HStack(spacing: 10) {+ Button {+ notice = "請選取 JSON 配置檔案。"+ sendlog(title: "TTS匯入", message: "開啟文件選擇器")+ importing = true+ } label: {+ Label("匯入 JSON", systemImage: "square.and.arrow.down")+ }+ .buttonStyle(.bordered)+ .disabled(isReadingImport)++ Button {+ do {+ let url = try filter.configuration.write(to: documentsDirectory)+ exportedURL = url+ notice = "已儲存至檔案分享目錄 Documents:\(url.lastPathComponent)"+ } catch { notice = error.localizedDescription }+ } label: {+ Label("匯出目前配置", systemImage: "square.and.arrow.up")+ }+ .buttonStyle(.bordered)+ }++ if isReadingImport { ProgressView("讀取配置中…") }+ if let notice {+ Text(notice)+ .font(.caption)+ .foregroundStyle(.orange)+ .fixedSize(horizontal: false, vertical: true)+ }+ if let url = exportedURL {+ ShareLink("分享最近匯出的配置", item: url)+ }+ Text("匯出與備份存放在與 log.txt 相同的 Documents 目錄。")+ .font(.caption).foregroundStyle(.secondary)++ Divider()++ Text("訊息測試")+ .font(.subheadline.weight(.semibold))+ TextField("輸入訊息測試", text: $inputText)+ .textFieldStyle(.roundedBorder)++ if !inputText.isEmpty {+ VStack(alignment: .leading, spacing: 6) {+ Text("處理後訊息:")+ .font(.subheadline)+ .foregroundColor(.gray)+ Text(processedText.isEmpty ? "(會被過濾,不朗讀)" : processedText)+ .padding()+ .frame(maxWidth: .infinity, alignment: .leading)+ .background(Color(.secondarySystemBackground))+ .cornerRadius(8)+ }+ } }+ .frame(maxWidth: .infinity, alignment: .leading) }- if isReadingImport { ProgressView("讀取配置中…") }- if let notice {- Text(notice)- .font(.caption)- .foregroundStyle(.orange)- .fixedSize(horizontal: false, vertical: true)- }- if let url = exportedURL { ShareLink("分享最近匯出的配置", item: url) }- Text("匯出與備份存放在與 log.txt 相同的 Documents 目錄。")- .font(.caption).foregroundStyle(.secondary)- Text("朗讀過濾設定")- .font(.headline)- - TextField("輸入訊息測試", text: $inputText)- .textFieldStyle(.roundedBorder)- - if !processedText.isEmpty {- VStack(alignment: .leading, spacing: 8) {- Text("處理後訊息:")- .font(.subheadline)- .foregroundColor(.gray)- Text(processedText)- .padding()- .frame(maxWidth: .infinity, alignment: .leading)- .background(Color(.secondarySystemBackground))- .cornerRadius(8)++ GroupBox("基本清理") {+ VStack(alignment: .leading, spacing: 12) {+ Toggle("移除 URL", isOn: $filter.removeURLs)+ Toggle("移除表情符號 Emoji", isOn: $filter.removeEmoji)+ Toggle("移除純數字", isOn: $filter.removePureNumbers)++ Divider()++ VStack(alignment: .leading, spacing: 6) {+ Text("重複無意義字串")+ .font(.subheadline.weight(.semibold))+ Picker("", selection: $filter.noisePolicy) {+ Text("不處理").tag(SpeechNoisePolicy.off)+ Text("整句都是重複字元時跳過").tag(SpeechNoisePolicy.skipWholeMessage)+ Text("移除重複字元後朗讀").tag(SpeechNoisePolicy.stripNoiseTokens)+ }+ .labelsHidden()+ .pickerStyle(.menu)+ Text("只處理 ASCII 英數字元重複 3 次以上(wwww、aaaa、bbb);hahaha、abc 與混合正常詞句不會被排除。")+ .font(.caption).foregroundStyle(.secondary)+ .fixedSize(horizontal: false, vertical: true)+ } }+ .frame(maxWidth: .infinity, alignment: .leading) }- - Divider()- - Toggle("移除 URL", isOn: $filter.removeURLs)- - Toggle("移除表情符號 Emoji", isOn: $filter.removeEmoji)- - Toggle("移除純數字", isOn: $filter.removePureNumbers)-- Picker("重複無意義字串", selection: $filter.noisePolicy) {- Text("不處理").tag(SpeechNoisePolicy.off)- Text("整句都是重複字元時跳過").tag(SpeechNoisePolicy.skipWholeMessage)- Text("移除重複字元後朗讀").tag(SpeechNoisePolicy.stripNoiseTokens)- }- .pickerStyle(.menu)- Text("只處理 ASCII 英數字元重複 3 次以上(wwww、aaaa、bbb);hahaha、abc 與混合正常詞句不會被排除。")- .font(.caption).foregroundStyle(.secondary) - VStack(alignment: .leading) {- Text("排除關鍵字")- HStack {- TextField("新增排除字", text: $newBlockWord)- .textFieldStyle(.roundedBorder)- Button("加入") {- if !newBlockWord.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !filter.blockKeywords.contains(newBlockWord) {- filter.blockKeywords.append(newBlockWord)- newBlockWord = ""+ GroupBox("排除關鍵字") {+ VStack(alignment: .leading, spacing: 8) {+ HStack(spacing: 8) {+ TextField("新增排除字", text: $newBlockWord)+ .textFieldStyle(.roundedBorder)+ Button("加入") {+ if !newBlockWord.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !filter.blockKeywords.contains(newBlockWord) {+ filter.blockKeywords.append(newBlockWord)+ newBlockWord = ""+ } }+ .buttonStyle(.bordered) }+ Text("排除字清單與啟用狀態在右側;點擊列可編輯。")+ .font(.caption).foregroundStyle(.secondary) }+ .frame(maxWidth: .infinity, alignment: .leading) }- - VStack(alignment: .leading) {- Text("替換關鍵字")- HStack {- TextField("原字", text: $newReplaceWord)- .textFieldStyle(.roundedBorder)- TextField("替換字", text: $newReplacement)- .textFieldStyle(.roundedBorder)- .frame(width: 60)- Button("加入") {- if !newReplaceWord.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {- let isNew = filter.replaceKeywords[newReplaceWord] == nil- filter.replaceKeywords[newReplaceWord] = newReplacement- if isNew { filter.replacementOrder = filter.orderedReplacementKeys.filter { $0 != newReplaceWord } + [newReplaceWord] }- newReplaceWord = ""- newReplacement = "B"++ GroupBox("替換關鍵字") {+ VStack(alignment: .leading, spacing: 8) {+ HStack(spacing: 8) {+ TextField("原字", text: $newReplaceWord)+ .textFieldStyle(.roundedBorder)+ TextField("替換字", text: $newReplacement)+ .textFieldStyle(.roundedBorder)+ .frame(width: 60)+ Button("加入") {+ if !newReplaceWord.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {+ let isNew = filter.replaceKeywords[newReplaceWord] == nil+ filter.replaceKeywords[newReplaceWord] = newReplacement+ if isNew { filter.replacementOrder = filter.orderedReplacementKeys.filter { $0 != newReplaceWord } + [newReplaceWord] }+ newReplaceWord = ""+ newReplacement = "B"+ } }+ .buttonStyle(.bordered) }+ Text("替換字清單與啟用狀態在右側;點擊列可編輯。")+ .font(.caption).foregroundStyle(.secondary) }+ .frame(maxWidth: .infinity, alignment: .leading) } }+ .frame(maxWidth: .infinity, alignment: .leading) } // 右邊:列表區