Ariver
2026-07-01 2ba1056c56435f8d98110eca18dba90dc344de66
Polish Windows overlay preview
5 files modified
236 ■■■■ changed files
CHANGELOG.md 16 ●●●●● patch | view | raw | blame | history
CODEGRAPH.md 1 ●●●● patch | view | raw | blame | history
privatevoice.src/app.go 4 ●●●● patch | view | raw | blame | history
privatevoice.src/internal/overlay/overlay_windows.go 211 ●●●● patch | view | raw | blame | history
privatevoice.src/scripts/build-windows-preview.sh 4 ●●●● patch | view | raw | blame | history
CHANGELOG.md
@@ -1,5 +1,21 @@
# Changelog
## v2.1.39 (2026-07-01)
### Windows 动态指示视觉升级
- **Windows overlay 升级为轻量炫彩胶囊**:保留原生 Win32 overlay 路线,不改热键、录音和识别链路,只提升视觉表现。
- **胶囊窗口更轻更透**:降低背景不透明度,加入更柔和的边框和内层高光,减轻之前厚重发闷的观感。
- **柱条动画更接近 macOS 风格**:柱条从 5 根扩展到 11 根,录音/处理中加入更丰富的动态起伏、光谱过渡和发光层。
- **仍保留 Windows 原生行为**:继续使用现有顶层窗、非激活、拖拽和定位逻辑,避免把视觉升级扩大成平台重构。
### 构建
- build: `20260701.2356`
- 说明: 本版本为 Windows 动态指示视觉升级后的预览候选包。
---
## v2.1.38 (2026-06-29)
### 性能诊断
CODEGRAPH.md
@@ -35,6 +35,7 @@
- Windows preview supports `SenseVoice` only.
- Non-SenseVoice models remain visible in settings but are disabled through `model.IsModelSupportedInCurrentBuild`.
- Windows unsupported reason: `windows_preview_unsupported`.
- Windows overlay remains native Win32/GDI+, with a larger translucent spectrum-style capsule indicator for recording states.
- Build script: `privatevoice.src/scripts/build-windows-preview.sh`.
- Evidence verifier: `privatevoice.src/scripts/verify-windows-qa-evidence.sh`.
- Runtime package includes:
privatevoice.src/app.go
@@ -28,8 +28,8 @@
)
const (
    appVersion        = "2.1.38"
    appBuild          = "20260629.2014"
    appVersion        = "2.1.39"
    appBuild          = "20260701.2356"
    appDisplayVersion = appVersion + " (build " + appBuild + ")"
    appName           = "PrivateVoice Dictation"
privatevoice.src/internal/overlay/overlay_windows.go
@@ -49,10 +49,15 @@
// Colors (ARGB)
const (
    clrCapsuleBG = 0xD91C1C1E
    clrBorder    = 0x20FFFFFF
    clrText      = 0xFFB4B4B4
    clrCapsuleBG = 0xA6101318
    clrCapsuleHi = 0x18FFFFFF
    clrBorder    = 0x2EFFFFFF
    clrText      = 0xFFE7EDF5
    clrTextMuted = 0xFFB6C3D2
    clrBlue      = 0xFF007AFF
    clrCyan      = 0xFF2ED3FF
    clrViolet    = 0xFF7A6CFF
    clrPink      = 0xFFFF5EB8
    clrGreen     = 0xFF34C759
    clrRed       = 0xFFFF3B30
    clrOrange    = 0xFFFF9500
@@ -60,15 +65,15 @@
// Dimensions (logical px)
const (
    capW   = 170
    capH   = 48
    capR   = 24
    barW   = 5
    barGap = 4
    barR   = 2.5
    nBars  = 5
    txtSz  = 12.0
    txtGap = 12
    capW   = 420
    capH   = 92
    capR   = 46
    barW   = 7
    barGap = 6
    barR   = 3.5
    nBars  = 11
    txtSz  = 17.0
    txtGap = 20
)
// ---- Win32 structs ----
@@ -230,13 +235,13 @@
func statusColor(s Status) uint32 {
    switch s {
    case StatusLoading:
        return clrBlue
        return clrCyan
    case StatusReady, StatusDone:
        return clrGreen
    case StatusRecording, StatusError:
    case StatusRecording, StatusFreetalking:
        return clrCyan
    case StatusError:
        return clrRed
    case StatusFreetalking:
        return clrBlue
    case StatusProcessing, StatusCancelled, StatusNoVoice, StatusNoContent:
        return clrOrange
    default:
@@ -288,7 +293,7 @@
        done:     make(chan struct{}),
    }
    for i := range o.barHeights {
        o.barHeights[i] = 14
        o.barHeights[i] = 16
    }
    go o.run()
    return o
@@ -472,8 +477,8 @@
                text = cmd.text
                barClr = statusColor(cmd.status)
            case vol := <-o.volCh:
                copy(volumes[0:4], volumes[1:5])
                volumes[4] = vol
                copy(volumes[0:nBars-1], volumes[1:nBars])
                volumes[nBars-1] = vol
            case pos := <-o.posCh:
                o.posX, o.posY = pos[0], pos[1]
                uSetWindowPos.Call(hwnd, 0, uintptr(o.posX), uintptr(o.posY), 0, 0,
@@ -532,7 +537,7 @@
        o.animTime += 0.033
        o.animate(status, volumes)
        o.renderFrame(memDC, hwnd, screenDC, font, strFmt, barClr, text, alpha)
        o.renderFrame(memDC, hwnd, screenDC, font, strFmt, status, barClr, text, alpha)
    }
}
@@ -544,12 +549,14 @@
    switch status {
    case StatusLoading:
        for i := 0; i < nBars; i++ {
            pulse := math.Sin(t*2+float64(i)*0.3)*0.5 + 0.5
            o.barHeights[i] = (10 + pulse*20) * s
            pulse := math.Sin(t*2.4+float64(i)*0.42)*0.5 + 0.5
            o.barHeights[i] = (12 + pulse*28) * s
        }
    case StatusReady, StatusDone:
        for i := 0; i < nBars; i++ {
            o.barHeights[i] = o.barHeights[i]*0.85 + 14*s*0.15
            pulse := math.Sin(t*1.4+float64(i)*0.25)*0.5 + 0.5
            target := (15 + pulse*7) * s
            o.barHeights[i] = o.barHeights[i]*0.78 + target*0.22
        }
    case StatusRecording, StatusFreetalking:
        hasReal := false
@@ -562,29 +569,29 @@
        for i := 0; i < nBars; i++ {
            var target float64
            if hasReal {
                target = (8 + volumes[i]*32) * s
                target = (10 + volumes[i]*44) * s
            } else {
                wave := math.Sin(t*4+float64(i)*0.8)*0.5 + 0.5
                rnd := math.Sin(t*7+float64(i)*1.5) * 0.3
                target = (10 + (wave+rnd)*25) * s
                wave := math.Sin(t*4.6+float64(i)*0.55)*0.5 + 0.5
                rnd := math.Sin(t*7.5+float64(i)*1.1) * 0.2
                target = (12 + (wave+rnd)*30) * s
            }
            o.barHeights[i] = o.barHeights[i]*0.6 + target*0.4
            o.barHeights[i] = o.barHeights[i]*0.52 + target*0.48
        }
    case StatusProcessing:
        for i := 0; i < nBars; i++ {
            wave := math.Sin(t*3+float64(i)*0.6)*0.5 + 0.5
            o.barHeights[i] = (12 + wave*18) * s
            wave := math.Sin(t*3.2+float64(i)*0.5)*0.5 + 0.5
            o.barHeights[i] = (14 + wave*22) * s
        }
    default:
        for i := 0; i < nBars; i++ {
            o.barHeights[i] = o.barHeights[i]*0.85 + 14*s*0.15
            o.barHeights[i] = o.barHeights[i]*0.8 + 16*s*0.2
        }
    }
}
// ---- Rendering ----
func (o *winOverlay) renderFrame(memDC, hwnd, screenDC, font, strFmt uintptr, barClr uint32, text string, alpha byte) {
func (o *winOverlay) renderFrame(memDC, hwnd, screenDC, font, strFmt uintptr, status Status, barClr uint32, text string, alpha byte) {
    var gfx uintptr
    gpFromHDC.Call(memDC, uintptr(unsafe.Pointer(&gfx)))
    if gfx == 0 {
@@ -596,11 +603,21 @@
    gpSetTextHint.Call(gfx, textHintAntiAlias)
    gpClear.Call(gfx, 0) // transparent
    // Capsule background
    fillRoundRect(gfx, 0, 0, float64(o.sW), float64(o.sH), o.sCapR, clrCapsuleBG)
    capsuleW := float64(o.sW)
    capsuleH := float64(o.sH)
    capsuleR := o.sCapR
    edgeInset := 1.5 * o.scale
    contentInsetX := 18.0 * o.scale
    contentInsetY := 14.0 * o.scale
    contentH := capsuleH - contentInsetY*2
    // Colored halo + lighter translucent body.
    fillRoundRect(gfx, 0, 0, capsuleW, capsuleH, capsuleR, scaleAlpha(barClr, 0.16))
    fillRoundRect(gfx, edgeInset, edgeInset, capsuleW-edgeInset*2, capsuleH-edgeInset*2, maxf(capsuleR-edgeInset, 1), clrCapsuleBG)
    fillRoundRect(gfx, contentInsetX, contentInsetY, capsuleW-contentInsetX*2, contentH*0.58, maxf(contentH*0.29, 8*o.scale), clrCapsuleHi)
    // Inner border
    strokeRoundRect(gfx, 0.5, 0.5, float64(o.sW)-1, float64(o.sH)-1, o.sCapR-0.5, clrBorder, 1)
    strokeRoundRect(gfx, 0.5, 0.5, capsuleW-1, capsuleH-1, maxf(capsuleR-0.5, 1), clrBorder, float32(maxf(1.0*o.scale, 1)))
    // Layout: bars + optional text, centered
    barsW := float32(float64(nBars)*o.sBarW + float64(nBars-1)*o.sBarGap)
@@ -608,30 +625,66 @@
    if text != "" && font != 0 {
        textW = measureText(gfx, font, strFmt, text)
    }
    maxTextW := float32(capsuleW-contentInsetX*2) - barsW - float32(o.sTxtGap)
    if maxTextW < 0 {
        maxTextW = 0
    }
    if textW > maxTextW {
        textW = maxTextW
    }
    totalW := barsW
    if textW > 0 {
        totalW += float32(o.sTxtGap) + textW
    }
    startX := (float32(o.sW) - totalW) / 2
    // Bar bed glow
    barBedX := float64(startX) - 12*o.scale
    barBedW := float64(barsW) + 24*o.scale
    fillRoundRect(
        gfx,
        barBedX,
        contentInsetY+4*o.scale,
        barBedW,
        contentH-8*o.scale,
        maxf((contentH-8*o.scale)/2, 6*o.scale),
        scaleAlpha(barClr, 0.10),
    )
    // Bars
    for i := 0; i < nBars; i++ {
        bx := startX + float32(float64(i)*(o.sBarW+o.sBarGap))
        bh := float32(o.barHeights[i])
        if bh < float32(6*o.scale) {
            bh = float32(6 * o.scale)
        if bh < float32(8*o.scale) {
            bh = float32(8 * o.scale)
        }
        if bh > float32(40*o.scale) {
            bh = float32(40 * o.scale)
        if bh > float32(56*o.scale) {
            bh = float32(56 * o.scale)
        }
        by := (float32(o.sH) - bh) / 2
        fillRoundRect(gfx, float64(bx), float64(by), o.sBarW, float64(bh), o.sBarR, barClr)
        barColor := barFillColor(status, i, o.animTime, barClr)
        glowH := float64(bh) + 18*o.scale
        glowY := float64(by) - 9*o.scale
        fillRoundRect(
            gfx,
            float64(bx)-o.sBarW*0.4,
            glowY,
            o.sBarW*1.8,
            glowH,
            o.sBarR*1.8,
            scaleAlpha(barColor, 0.18),
        )
        fillRoundRect(gfx, float64(bx), float64(by), o.sBarW, float64(bh), o.sBarR, barColor)
    }
    // Text
    if text != "" && font != 0 && strFmt != 0 {
        tx := startX + barsW + float32(o.sTxtGap)
        drawText(gfx, font, strFmt, text, tx, 0, textW+4, float32(o.sH), clrText)
        textColor := uint32(clrText)
        if status == StatusReady || status == StatusDone {
            textColor = uint32(clrTextMuted)
        }
        drawText(gfx, font, strFmt, text, tx, 0, textW+4, float32(o.sH), textColor)
    }
    // UpdateLayeredWindow
@@ -642,8 +695,84 @@
        memDC, uintptr(unsafe.Pointer(&ptSrc)), 0, uintptr(unsafe.Pointer(&blend)), ulwAlpha)
}
func barFillColor(status Status, index int, t float64, base uint32) uint32 {
    progress := 0.0
    if nBars > 1 {
        progress = float64(index) / float64(nBars-1)
    }
    switch status {
    case StatusRecording, StatusFreetalking:
        return mixColor(spectralColor(progress+t*0.08), base, 0.18)
    case StatusLoading, StatusProcessing:
        return mixColor(spectralColor(progress+t*0.16), base, 0.32)
    case StatusReady, StatusDone:
        return mixColor(spectralColor(progress+t*0.03), clrGreen, 0.70)
    case StatusError:
        return mixColor(clrRed, clrPink, progress*0.35)
    case StatusCancelled, StatusNoVoice, StatusNoContent:
        return mixColor(clrOrange, clrPink, progress*0.22)
    default:
        return base
    }
}
// ---- GDI+ drawing helpers ----
func clamp01(v float64) float64 {
    if v < 0 {
        return 0
    }
    if v > 1 {
        return 1
    }
    return v
}
func maxf(a, b float64) float64 {
    if a > b {
        return a
    }
    return b
}
func argb(a, r, g, b byte) uint32 {
    return uint32(a)<<24 | uint32(r)<<16 | uint32(g)<<8 | uint32(b)
}
func colorChannels(c uint32) (byte, byte, byte, byte) {
    return byte(c >> 24), byte(c >> 16), byte(c >> 8), byte(c)
}
func mixColor(a, b uint32, t float64) uint32 {
    t = clamp01(t)
    aa, ar, ag, ab := colorChannels(a)
    ba, br, bg, bb := colorChannels(b)
    mix := func(x, y byte) byte {
        return byte(math.Round(float64(x) + (float64(y)-float64(x))*t))
    }
    return argb(mix(aa, ba), mix(ar, br), mix(ag, bg), mix(ab, bb))
}
func scaleAlpha(c uint32, factor float64) uint32 {
    factor = clamp01(factor)
    a, r, g, b := colorChannels(c)
    return argb(byte(math.Round(float64(a)*factor)), r, g, b)
}
func spectralColor(progress float64) uint32 {
    progress = progress - math.Floor(progress)
    switch {
    case progress < 0.25:
        return mixColor(clrCyan, clrBlue, progress/0.25)
    case progress < 0.55:
        return mixColor(clrBlue, clrViolet, (progress-0.25)/0.30)
    case progress < 0.80:
        return mixColor(clrViolet, clrPink, (progress-0.55)/0.25)
    default:
        return mixColor(clrPink, 0xFFFF8AA8, (progress-0.80)/0.20)
    }
}
func makeRoundRectPath(x, y, w, h, r float64) uintptr {
    var path uintptr
    gpCreatePath.Call(fillModeAlternate, uintptr(unsafe.Pointer(&path)))
privatevoice.src/scripts/build-windows-preview.sh
@@ -4,8 +4,8 @@
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
APP_VERSION="2.1.38"
APP_BUILD="20260629.2014"
APP_VERSION="2.1.39"
APP_BUILD="20260701.2356"
ARCH="${1:-amd64}"
if [[ "$ARCH" != "amd64" ]]; then