Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/bundles.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ concurrency:
cancel-in-progress: true

env:
SNIPVOICE_VERSION: "1.2.0"
SNIPVOICE_VERSION: "1.2.1"
SNIPVOICE_CHANNEL: "stable"
SNIPVOICE_RELEASE_LABEL: "1.2.0"
SNIPVOICE_RELEASE_LABEL: "1.2.1"

jobs:
windows:
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

- Batch contiguous native audio packets to reduce disk synchronization pressure while preserving gaps and pause/stop tails.
- Show recording time, pause state, and partial/interrupted status in the floating indicator while the manager is minimized or hidden.
- Preserve queued audio when stopping races the native helper's exit; continue reporting overflow and unexpected exits.
- Reject inconsistent runtime, installer, or bundle version/channel metadata before Windows release preparation.
- Name transcription and summary models by what they offer, such as "Equilibrado · Parakeet" and "Máxima qualidade · Gemma 4 E4B", instead of bare model codes.
- List models from lowest to highest quality in Ditado, Configurações > Gravação, and Configurações > Modelos.

Expand Down
13 changes: 7 additions & 6 deletions RELEASE-WINDOWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ Set the clean FFmpeg evidence directory first:

```powershell
$env:SNIPVOICE_FFMPEG_COMPLIANCE_DIR = 'C:\path\to\compliance'
.\prepare-release-windows.ps1 -Tag v1.2.0 -DryRun
.\prepare-release-windows.ps1 -Tag v1.2.0
.\prepare-release-windows.ps1 -Tag v1.2.1 -DryRun
.\prepare-release-windows.ps1 -Tag v1.2.1
```

The helper requires clean `main` at `origin/main`, runs unittest, preserves the
compliance manifest/native capture/runtime probes in the existing builders, and
hashes the Windows installer. macOS capture, signing, notarization, and release
publication remain external gates.
The helper requires matching source/runtime, installer, and bundle-workflow
version/channel metadata, then clean `main` at `origin/main`. It runs unittest,
preserves the compliance manifest/native capture/runtime probes in the existing
builders, and hashes the Windows installer. macOS capture, signing, notarization,
and release publication remain external gates.
2 changes: 1 addition & 1 deletion installer/snipvoice.iss
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
; Snipvoice never migrates another application's user data.

#define MyAppName "SnipVoice"
#define MyAppVersion "1.2.0"
#define MyAppVersion "1.2.1"
#define MyAppChannel "stable"
#if MyAppChannel == "beta"
#define MyAppDisplayVersion MyAppVersion + " beta 3"
Expand Down
16 changes: 16 additions & 0 deletions prepare-release-windows.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,22 @@ function Get-DeclaredVersion {
}
$declared = $Matches[1]
if ($content -notmatch '(?m)^Channel: stable\r?$') { throw 'A stable release is required.' }
$installer = Get-Content -Raw -LiteralPath 'installer/snipvoice.iss'
$workflow = Get-Content -Raw -LiteralPath '.github/workflows/bundles.yml'
$checks = @(
@{ Text = $content; Pattern = '(?m)^APP_VERSION = "([^"]+)"'; Expected = $declared; Label = 'runtime version' },
@{ Text = $content; Pattern = '(?m)^RELEASE_CHANNEL = "([^"]+)"'; Expected = 'stable'; Label = 'runtime channel' },
@{ Text = $installer; Pattern = '(?m)^#define MyAppVersion "([^"]+)"'; Expected = $declared; Label = 'installer version' },
@{ Text = $installer; Pattern = '(?m)^#define MyAppChannel "([^"]+)"'; Expected = 'stable'; Label = 'installer channel' },
@{ Text = $workflow; Pattern = '(?m)^ SNIPVOICE_VERSION: "([^"]+)"'; Expected = $declared; Label = 'workflow version' },
@{ Text = $workflow; Pattern = '(?m)^ SNIPVOICE_CHANNEL: "([^"]+)"'; Expected = 'stable'; Label = 'workflow channel' },
@{ Text = $workflow; Pattern = '(?m)^ SNIPVOICE_RELEASE_LABEL: "([^"]+)"'; Expected = $declared; Label = 'release label' }
)
foreach ($check in $checks) {
if ($check.Text -notmatch $check.Pattern -or $Matches[1] -ne $check.Expected) {
throw "Release $($check.Label) does not match the declared stable version."
}
}
return $declared
}

Expand Down
2 changes: 1 addition & 1 deletion source/docs/development.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Development

Snipvoice source is `1.2.0` on the `stable` channel (`v1.2.0`).
Snipvoice source is `1.2.1` on the `stable` channel (the upcoming `v1.2.1`).
Runtime dependencies and native voice dependencies remain in separate manifests.
Use an existing interpreter; do not implicitly update host tooling or packages.

Expand Down
15 changes: 12 additions & 3 deletions source/meeting_audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,12 +178,21 @@ def read_event(self, timeout=0.1):
return None

def command(self, command):
if self._process is None or self._process.poll() is not None:
process = self._process
if process is None:
raise MeetingAudioError(tr("O capturador de áudio não está ativo."))
if process.poll() is not None:
if command == "stop":
# The controller must drain queued PCM and inspect the terminal
# status even when the helper finished before the stop request.
return
raise MeetingAudioError(tr("O capturador de áudio não está ativo."))
try:
self._process.stdin.write((json.dumps({"command": command}) + "\n").encode())
self._process.stdin.flush()
process.stdin.write((json.dumps({"command": command}) + "\n").encode())
process.stdin.flush()
except (OSError, ValueError) as exc:
if command == "stop" and process.poll() is not None:
return
raise MeetingAudioError(tr("Não foi possível controlar a captura.")) from exc

def pause(self):
Expand Down
4 changes: 2 additions & 2 deletions source/snipvoice.pyw
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""Snipvoice - local push-to-talk dictation for Windows and macOS.
Version: 1.2.0
Version: 1.2.1
Channel: stable

Recording is explicit and local. Audio and transcripts are kept in recoverable
Expand Down Expand Up @@ -91,7 +91,7 @@ from meeting_library import MeetingLibrary
from meeting_support import MeetingController
from meeting_settings import resolve_meeting_settings, validate_hotkey_conflicts

APP_VERSION = "1.2.0"
APP_VERSION = "1.2.1"
RELEASE_CHANNEL = "stable"
BETA_NUMBER = 0
APP_DISPLAY_NAME = f"SnipVoice v{APP_VERSION}"
Expand Down
54 changes: 52 additions & 2 deletions source/tests/test_meeting_audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,61 @@ def test_truncated_payload_is_not_a_successful_take(self):

def fake_helper(self, body):
script = ("import json,struct,sys,time\n"
"def emit(event):\n"
" data=json.dumps(event).encode(); sys.stdout.buffer.write(struct.pack('<I',len(data))+data+struct.pack('<I',0)); sys.stdout.buffer.flush()\n" + body)
"def emit(event, payload=b''):\n"
" data=json.dumps(event).encode(); sys.stdout.buffer.write(struct.pack('<I',len(data))+data+struct.pack('<I',len(payload))+payload); sys.stdout.buffer.flush()\n" + body)
return NativeCapture(helper_path=sys.executable, popen=lambda argv, **options:
subprocess.Popen([sys.executable, "-u", "-c", script], **options))

@unittest.skipUnless(sys.platform in ("win32", "darwin"), "supported capture host required")
def test_stop_after_helper_exit_preserves_buffered_audio_and_terminal_errors(self):
for exit_code, terminal in ((0, True), (3, True), (1, False)):
with self.subTest(exit_code=exit_code, terminal=terminal):
ending = ("emit({'type':'stopped','generation':2,'reason':'transport_overflow'})\n"
if exit_code == 3 else "emit({'type':'stopped','generation':2})\n") if terminal else ""
capture = self.fake_helper(
"emit({'type':'ready','version':1,'generation':2})\n"
"sys.stdin.readline()\n"
"emit({'type':'audio','generation':2,'track':'system','rate':16000,"
"'channels':1,'frames':2,'timestamp':0,'sequence':0},struct.pack('<2f',.25,-.5))\n"
+ ending + f"sys.exit({exit_code})\n")
try:
capture.start(MeetingSettings(), 2)
capture._process.stdin.write(b"exit\n")
capture._process.stdin.flush()
capture._process.wait(timeout=3)
self.assertTrue(capture._done.wait(2))
capture.command("stop")
event, payload = capture.read_event(timeout=2)
self.assertEqual(event["type"], "audio")
self.assertEqual(payload, struct.pack("<2f", .25, -.5))
if terminal:
self.assertEqual(capture.read_event(timeout=2)[0]["type"], "stopped")
else:
with self.assertRaises(MeetingAudioError):
capture.read_event(timeout=.01)
if exit_code:
with self.assertRaises(MeetingAudioError):
capture.stop()
else:
capture.stop()
finally:
capture.stop(force=True)

def test_stop_tolerates_exit_during_pipe_write_but_reports_a_live_control_failure(self):
capture = NativeCapture()
process = mock.Mock()
capture._process = process
process.stdin.write.side_effect = BrokenPipeError("closed fixture pipe")
process.poll.side_effect = [None, 0]
capture.command("stop")
process.poll.side_effect = None
process.poll.return_value = None
with self.assertRaises(MeetingAudioError):
capture.command("stop")
process.poll.return_value = 0
with self.assertRaises(MeetingAudioError):
capture.command("pause")

@unittest.skipUnless(sys.platform in ("win32", "darwin"), "supported capture host required")
def test_stderr_flood_is_drained_and_stop_is_reaped(self):
capture = self.fake_helper("sys.stderr.buffer.write(b'x'*1048576); sys.stderr.buffer.flush()\n"
Expand Down
42 changes: 42 additions & 0 deletions source/tests/test_meeting_support.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
import struct
import shutil
import subprocess
import sys
import tempfile
import threading
Expand All @@ -11,6 +12,7 @@
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

from meeting_settings import MeetingSettings
from meeting_audio import NativeCapture
from meeting_library import AnnotationConflict, MeetingLibrary
from meeting_retention import ConfirmationRequired, OperationRecoveryError
from meeting_store import MeetingStore
Expand Down Expand Up @@ -230,6 +232,46 @@ def test_missing_mp3_encoder_preserves_recording_and_releases_capture(self):
self.assertEqual(len(list(self.controller.store.iter_audio(snapshot["session_id"]))), 1)
self.voice.release_meeting.assert_called_once_with("lease")

@unittest.skipUnless(sys.platform in ("win32", "darwin"), "supported capture host required")
def test_stop_racing_helper_exit_still_journals_buffered_audio(self):
script = (
"import json,struct,sys\n"
"generation=int(sys.argv[1])\n"
"def emit(event,payload=b''):\n"
" event['generation']=generation; data=json.dumps(event).encode(); "
"sys.stdout.buffer.write(struct.pack('<I',len(data))+data+struct.pack('<I',len(payload))+payload); sys.stdout.buffer.flush()\n"
"emit({'type':'ready','version':1})\n"
"sys.stdin.readline()\n"
"emit({'type':'audio','track':'system','rate':16000,'channels':1,'frames':2,"
"'timestamp':0,'sequence':0},struct.pack('<2f',.25,-.5))\n"
"emit({'type':'gap','track':'system','timestamp':.01,'reason':'transport_overflow'})\n"
"emit({'type':'stopped','reason':'transport_overflow'})\n"
"sys.exit(3)\n"
)
capture = NativeCapture(helper_path=sys.executable, popen=lambda argv, **options:
subprocess.Popen([sys.executable, "-u", "-c", script, argv[-1]], **options))
self.controller.capture_factory = lambda: capture
start = capture.start

def stop_after_exit(settings, generation):
start(settings, generation)
capture._process.stdin.write(b"exit\n")
capture._process.stdin.flush()
capture._process.wait(timeout=3)
self.controller.stop()

with patch.object(capture, "start", side_effect=stop_after_exit):
self.assertTrue(self.controller.start(MeetingSettings()))
self.controller._thread.join(4)
self.assertFalse(self.controller._thread.is_alive())
snapshot = self.controller.snapshot()
audio = list(self.controller.store.iter_audio(snapshot["session_id"]))
self.assertEqual([payload for _event, payload in audio], [struct.pack("<2f", .25, -.5)])
self.assertEqual(self.controller.store.get(snapshot["session_id"])["status"], "failed")
self.assertIn("disco", snapshot["error"])
self.voice.release_meeting.assert_called_once_with("lease")
self.assertFalse(capture._reader.is_alive())

def test_snapshot_exposes_bounded_real_waveform_envelopes(self):
values = [0.0, 0.25, -0.5, 0.1, 0.75, -0.2]
self.assertEqual(_amplitude_envelope(values, 2, points=2), [0.5, 0.75])
Expand Down
10 changes: 5 additions & 5 deletions source/tests/test_voice_packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,14 +385,14 @@ def test_release_metadata_is_synchronized(self):
source_version = re.search(r'^APP_VERSION = "([^"]+)"$', texts["source"], re.M)
installer_version = re.search(r'^#define MyAppVersion "([^"]+)"$', texts["installer"], re.M)
workflow_version = re.search(r'^ SNIPVOICE_VERSION: "([^"]+)"$', texts["workflow"], re.M)
self.assertEqual(source_version.group(1), "1.2.0")
self.assertEqual(installer_version.group(1), "1.2.0")
self.assertEqual(workflow_version.group(1), "1.2.0")
self.assertIn('Version: 1.2.0\nChannel: stable', texts["source"])
self.assertEqual(source_version.group(1), "1.2.1")
self.assertEqual(installer_version.group(1), "1.2.1")
self.assertEqual(workflow_version.group(1), "1.2.1")
self.assertIn('Version: 1.2.1\nChannel: stable', texts["source"])
self.assertIn('RELEASE_CHANNEL = "stable"', texts["source"])
self.assertIn('#define MyAppChannel "stable"', texts["installer"])
self.assertIn('SNIPVOICE_CHANNEL: "stable"', texts["workflow"])
self.assertIn('SNIPVOICE_RELEASE_LABEL: "1.2.0"', texts["workflow"])
self.assertIn('SNIPVOICE_RELEASE_LABEL: "1.2.1"', texts["workflow"])
self.assertIn('#define MyAppDisplayVersion MyAppVersion + " beta 3"',
texts["installer"])
self.assertIn('#define MyInstallerVersion MyAppVersion + "-beta.3"',
Expand Down
54 changes: 53 additions & 1 deletion test-prepare-release-windows.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,22 @@ $global:releasePreparationTestState = @{ Mode = 'ok'; Gates = 0 }
try {
New-Item -ItemType Directory -Path $sandbox | Out-Null
New-Item -ItemType Directory -Path (Join-Path $sandbox 'source') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $sandbox 'installer') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $sandbox '.github/workflows') -Force | Out-Null
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'prepare-release-windows.ps1') -Destination $sandbox
'Version: 1.2.3
Channel: stable
APP_VERSION = "1.2.3"
RELEASE_CHANNEL = "stable"
' | Set-Content -LiteralPath (Join-Path $sandbox 'source/snipvoice.pyw')
'#define MyAppVersion "1.2.3"
#define MyAppChannel "stable"
' | Set-Content -LiteralPath (Join-Path $sandbox 'installer/snipvoice.iss')
'env:
SNIPVOICE_VERSION: "1.2.3"
SNIPVOICE_CHANNEL: "stable"
SNIPVOICE_RELEASE_LABEL: "1.2.3"
' | Set-Content -LiteralPath (Join-Path $sandbox '.github/workflows/bundles.yml')

function git {
param([Parameter(ValueFromRemainingArguments)] $ArgumentList)
Expand All @@ -36,6 +48,9 @@ Channel: stable
function python {
$global:releasePreparationTestState.Gates += 1
$global:LASTEXITCODE = if ($global:releasePreparationTestState.Mode -eq 'gate-fail') { 8 } else { 0 }
if ($global:releasePreparationTestState.Mode -eq 'metadata-after-tests') {
'#define MyAppVersion "9.9.9"' | Set-Content -LiteralPath (Join-Path $sandbox 'installer/snipvoice.iss')
}
}

function cmd { throw 'A dry run must never invoke a package builder.' }
Expand Down Expand Up @@ -66,7 +81,44 @@ Channel: stable
throw "A rejected checkout ran gates: $($case.Key)"
}
}
Write-Host 'snipvoice: 7 release preparation scenarios passed'
$metadataCases = @(
@{ Path = 'source/snipvoice.pyw'; Before = 'APP_VERSION = "1.2.3"'; After = 'APP_VERSION = "9.9.9"'; Error = 'runtime version' },
@{ Path = 'source/snipvoice.pyw'; Before = 'RELEASE_CHANNEL = "stable"'; After = 'RELEASE_CHANNEL = "beta"'; Error = 'runtime channel' },
@{ Path = 'installer/snipvoice.iss'; Before = 'MyAppVersion "1.2.3"'; After = 'MyAppVersion "9.9.9"'; Error = 'installer version' },
@{ Path = 'installer/snipvoice.iss'; Before = 'MyAppChannel "stable"'; After = 'MyAppChannel "beta"'; Error = 'installer channel' },
@{ Path = '.github/workflows/bundles.yml'; Before = 'SNIPVOICE_VERSION: "1.2.3"'; After = 'SNIPVOICE_VERSION: "9.9.9"'; Error = 'workflow version' },
@{ Path = '.github/workflows/bundles.yml'; Before = 'SNIPVOICE_CHANNEL: "stable"'; After = 'SNIPVOICE_CHANNEL: "beta"'; Error = 'workflow channel' },
@{ Path = '.github/workflows/bundles.yml'; Before = 'SNIPVOICE_RELEASE_LABEL: "1.2.3"'; After = 'SNIPVOICE_RELEASE_LABEL: "9.9.9"'; Error = 'release label' }
)
foreach ($case in $metadataCases) {
$global:releasePreparationTestState.Mode = 'ok'
$global:releasePreparationTestState.Gates = 0
$path = Join-Path $sandbox $case.Path
$before = Get-Content -Raw -LiteralPath $path
$before.Replace($case.Before, $case.After) | Set-Content -LiteralPath $path
$caught = $false
try {
& (Join-Path $sandbox 'prepare-release-windows.ps1') -Tag v1.2.3 -DryRun
} catch {
if ($_.Exception.Message -notmatch $case.Error) { throw }
$caught = $true
} finally {
$before | Set-Content -LiteralPath $path
}
if (-not $caught) { throw "Mismatched release metadata was accepted: $($case.Path) $($case.Before)" }
if ($global:releasePreparationTestState.Gates -ne 0) { throw 'Mismatched metadata ran test gates.' }
}
$global:releasePreparationTestState.Mode = 'metadata-after-tests'
$global:releasePreparationTestState.Gates = 0
$caught = $false
try {
& (Join-Path $sandbox 'prepare-release-windows.ps1') -Tag v1.2.3 -DryRun
} catch {
if ($_.Exception.Message -notmatch 'installer version') { throw }
$caught = $true
}
if (-not $caught) { throw 'Metadata drift during tests was accepted.' }
Write-Host 'snipvoice: 15 release preparation scenarios passed'
}
finally {
Set-Location $original
Expand Down
Loading