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
69 changes: 58 additions & 11 deletions SoundVisualizer.Core/Ai/RealtimeAiPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ namespace SoundVisualizer.Ai
/// 정규 위협음이나 다음 정규 결과가 늘 이기고, 사용자 지정이 하나라도 있으면 미리보기를 쓰지 않는다. Silero 를 불러오거나 돌리다
/// 실패해도 정규 분류는 그대로 돈다.
///
/// 결과 알림(Android 의 onClassificationChanged, #412): 정규 결과를 새로 게시하거나 지울 때(틱 스레드), 미리보기를 게시할 때(미리보기
/// 스레드) 만들 때 받은 <c>classificationChanged</c> 를 부른다. 개발자 HUD 가 100ms 타이머를 기다리지 않고 바로 읽게 하는 깨우기일 뿐이고
/// 값은 싣지 않는다. 받는 쪽은 <see cref="LastDisplayClassification"/> 을 다시 읽는다. 시작·멈춤·닫기와 미리보기 만료는 알리지 않는다
/// (부르는 쪽이 아는 일이거나 시간이 지나 생기는 일이라 타이머가 읽는다).
///
/// PC 에서 바꾼 것:
/// - <see cref="Stop"/> 이 돌고 있는 틱을 기다리고 마지막 결과를 바로 숨기며, <see cref="Start"/> 는 앞 틱이 끝난 뒤에 비운다.
/// Android 서비스는 늦게 끝난 추론이 비운 뒤의 결과를 덮지 않도록 읽는 쪽을 떼고 AI 재시작을 미뤘는데, 여기서는 그럴 필요가 없다.
Expand Down Expand Up @@ -73,6 +78,8 @@ public sealed class RealtimeAiPipeline : IDisposable
private readonly IYamnetModel _model;
private readonly Func<IReadOnlyDictionary<string, string>> _mappingOverrides;
private readonly Action<Exception>? _tickFailed;
private readonly Action? _classificationChanged;
private int _classificationChangedFailed;
private readonly Func<long> _clock;
private readonly Func<long, CancellationToken, bool> _waitForNextTick;
private readonly bool _runScheduler;
Expand Down Expand Up @@ -118,6 +125,7 @@ public sealed class RealtimeAiPipeline : IDisposable
/// (이 생성자로 만드는 테스트의 기본). 앱은 <see cref="Create"/> 가 넣는다.
/// </param>
/// <param name="nanoClock">미리보기 시각을 재는 단조 나노초. 테스트가 시각을 정한다. 기본은 <see cref="Stopwatch"/>.</param>
/// <param name="classificationChanged">화면용 결과가 바뀌었을 때 틱 스레드나 미리보기 스레드에서 부른다(<see cref="Create"/> 참고).</param>
internal RealtimeAiPipeline(
int captureSampleRate,
int channels,
Expand All @@ -129,7 +137,8 @@ internal RealtimeAiPipeline(
Func<long, CancellationToken, bool>? waitForNextTick = null,
bool runScheduler = true,
Func<ISileroModel>? speechModel = null,
Func<long>? nanoClock = null)
Func<long>? nanoClock = null,
Action? classificationChanged = null)
{
ValidateArguments(captureSampleRate, channels, classes);
ArgumentNullException.ThrowIfNull(model);
Expand All @@ -139,6 +148,7 @@ internal RealtimeAiPipeline(
_model = model;
_mappingOverrides = mappingOverrides ?? (static () => NoOverrides);
_tickFailed = tickFailed;
_classificationChanged = classificationChanged;
_clock = clock ?? (static () => Environment.TickCount64);
_waitForNextTick = waitForNextTick ?? WaitForNextTick;
_runScheduler = runScheduler;
Expand Down Expand Up @@ -169,6 +179,11 @@ internal RealtimeAiPipeline(
/// 말소리 미리보기를 켤지. 켜면 같은 폴더의 silero_vad.onnx 를 미리보기 스레드가 불러온다. 그 파일이 없거나 다르면 미리보기만 꺼지고
/// (<see cref="AppLog.Warn"/>) 정규 분류는 돈다.
/// </param>
/// <param name="classificationChanged">
/// 화면용 결과(<see cref="LastDisplayClassification"/>)가 바뀌었다는 깨우기. 정규 결과를 게시하거나 지운 틱 스레드, 미리보기를 게시한
/// 미리보기 스레드에서 부른다. 값은 싣지 않으니 받는 쪽이 다시 읽는다. 추론 스레드를 붙잡지 않게 곧바로 돌아와야 한다
/// (UI 스레드에 넘기기만 한다, <c>Diagnostics.AiHudRefresh</c>). 던지면 삼키고 처음 한 번만 <see cref="AppLog.Warn"/> 으로 남긴다.
/// </param>
/// <exception cref="ArgumentOutOfRangeException">AI 가 받지 않는 캡처 레이트이거나 채널 수가 1 보다 작다.</exception>
/// <exception cref="FileNotFoundException">모델 파일이 없다.</exception>
/// <exception cref="InvalidDataException">모델 파일이 배포한 것과 다르거나 입출력이 YAMNet 계약과 다르다.</exception>
Expand All @@ -180,7 +195,8 @@ public static RealtimeAiPipeline Create(
YamnetClassMap classes,
Func<IReadOnlyDictionary<string, string>>? mappingOverrides = null,
Action<Exception>? tickFailed = null,
bool speechPreview = SpeechPreviewEnabled)
bool speechPreview = SpeechPreviewEnabled,
Action? classificationChanged = null)
{
// 모델을 읽기 전에 거른다. 화면만 돌 레이트라면 15MB 를 해시하고 세션을 만들 까닭이 없다.
ValidateArguments(captureSampleRate, channels, classes);
Expand All @@ -189,7 +205,8 @@ public static RealtimeAiPipeline Create(
{
string directory = Path.GetFullPath(modelDirectory);
return new RealtimeAiPipeline(captureSampleRate, channels, model, classes, mappingOverrides, tickFailed,
speechModel: speechPreview ? () => SileroInference.Create(directory) : null);
speechModel: speechPreview ? () => SileroInference.Create(directory) : null,
classificationChanged: classificationChanged);
}
catch
{
Expand Down Expand Up @@ -373,7 +390,7 @@ internal AiTickOutcome RunTickOnce(long nowMs)
// 앞 틱이 아직 돌고 있으면 기다리지 않고 건너뛴다(Kotlin 의 tryLock).
if (!Monitor.TryEnter(_inferLock)) return AiTickOutcome.Busy;

(AiTickOutcome Outcome, Exception? Failure) tick;
(AiTickOutcome Outcome, Exception? Failure, bool Changed) tick;
try
{
tick = RunTickLocked(nowMs);
Expand All @@ -386,6 +403,8 @@ internal AiTickOutcome RunTickOnce(long nowMs)
}

if (tick.Failure is not null) ReportTickFailure(tick.Failure);
// 추론 잠금을 놓은 뒤에 알린다. 받는 쪽이 느리거나 이 파이프라인을 다시 불러도 다음 틱과 닫기가 잠금에서 기다리지 않는다.
if (tick.Changed && !IsClosed) NotifyClassificationChanged();
return tick.Outcome;
}

Expand All @@ -410,28 +429,34 @@ internal AiTickOutcome RunTickOnce(long nowMs)
/// <summary>말소리 미리보기 스레드. 끄면 null.</summary>
internal SileroStreamWorker? SpeechWorkerForTest => _speechWorker;

private (AiTickOutcome Outcome, Exception? Failure) RunTickLocked(long nowMs)
/// <returns>Changed 는 이 틱이 정규 결과를 새로 게시했거나(완료) 매핑이 바뀌어 지웠는지다. 잠금을 놓은 뒤에 알린다.</returns>
private (AiTickOutcome Outcome, Exception? Failure, bool Changed) RunTickLocked(long nowMs)
{
// 정규 추론이 시작되면 미리보기를 새로 받아들이지 않는다. 이미 받아들인 것은 만료나 다음 정규 결과까지 둔다(Android 와 같다).
_speechGate.RegularStarted();
// Dispose 가 잠금을 잡기 직전에 위의 검사를 지났을 수 있으므로 잠금 안에서 다시 본다.
if (IsClosed) return (AiTickOutcome.Closed, null);
if (IsClosed) return (AiTickOutcome.Closed, null, false);

bool cleared = false;
try
{
// 무음이라 건너뛰는 틱에도 먼저 반영한다. 이전 매핑에서 확정한 위협음이 새 매핑으로 넘어가지 않게 한다.
// 매핑이 바뀌어 마지막 결과를 지웠으면 그 결과의 미리보기도 지운다.
if (_stage.UpdateMapping(CurrentOverrides())) ClearSpeechPreview();
if (_stage.UpdateMapping(CurrentOverrides()))
{
ClearSpeechPreview();
cleared = true;
}
}
catch (Exception ex)
{
return (AiTickOutcome.Failed, ex);
return (AiTickOutcome.Failed, ex, false);
}

if (!_gate.IsInferenceOpen(nowMs))
{
Interlocked.Increment(ref _silenceSkippedTicks);
return (AiTickOutcome.SkippedForSilence, null);
return (AiTickOutcome.SkippedForSilence, null, cleared);
}

try
Expand All @@ -441,12 +466,12 @@ internal AiTickOutcome RunTickOnce(long nowMs)
catch (Exception ex)
{
_gate.OnInferenceFailed(nowMs);
return (AiTickOutcome.Failed, ex);
return (AiTickOutcome.Failed, ex, cleared);
}
// 결과를 게시한 뒤에만 완료로 센다. 닫기 경합 테스트가 잠깐 보이는 결과 대신 완료 수로 확인한다(Kotlin 과 같다).
Interlocked.Increment(ref _inferenceCompletions);
_gate.OnInferenceCompleted(nowMs);
return (AiTickOutcome.Completed, null);
return (AiTickOutcome.Completed, null, true);
}

private void InferLocked()
Expand Down Expand Up @@ -507,6 +532,28 @@ private void OnSpeechBlock(long generation, long startFrame, long endFrame, floa
if (_nanoClock() < accepted.AcceptBeforeNs)
{
Volatile.Write(ref _displayPreview, new DisplayPreview(parent.Result, preview, accepted.ExpiresNs));
// 미리보기는 다음 정규 결과가 오면 사라져 100ms 도 못 사는 일이 있다. 타이머만으로는 HUD 와 기록이 그것을 통째로 놓친다.
NotifyClassificationChanged();
}
}

/// <summary>
/// 화면용 결과가 바뀌었다고 알린다. 틱 스레드(추론 잠금 밖)와 미리보기 스레드에서 부른다. 받는 쪽의 결함이 추론이나 미리보기를
/// 멈추면 안 되므로 예외는 삼키고, 틱마다 같은 줄이 쌓이지 않게 처음 한 번만 남긴다.
/// </summary>
private void NotifyClassificationChanged()
{
if (_classificationChanged is null) return;
try
{
_classificationChanged();
}
catch (Exception ex)
{
if (Interlocked.Exchange(ref _classificationChangedFailed, 1) == 0)
{
AppLog.Warn(Tag, "Classification-changed callback threw", ex);
}
}
}

Expand Down
20 changes: 16 additions & 4 deletions SoundVisualizer.Core/Ai/SileroInference.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,13 @@ public void Reset()

/// <summary>
/// 16kHz 512샘플로 말소리일 확률(0~1)을 낸다. 상태와 앞 덩어리의 끝을 다음 덩어리로 넘긴다. 할당하지 않는다.
///
/// 모델이 유한하지 않거나 0~1 밖의 값을 내도 던지지 않고 그대로 돌려준다(<see cref="IsValidProbability"/> 로 가른다). 예전에는 여기서
/// 던졌고, 미리보기 스레드가 그것을 모델 실패로 받아 캡처를 다시 시작할 때까지 미리보기가 조용히 꺼졌다. 깨진 값 하나는 지나가는
/// 일이라, 부르는 쪽(<see cref="SileroStreamWorker"/>)이 상태를 비우고 이어 간다(Android #456). 지금 모델(v6.2.3)은 NaN·무한대가
/// 섞인 소리에도 0~1 의 값을 내므로(SileroInferenceTests) 실제로는 드문 경우의 대비다.
/// </summary>
/// <exception cref="ObjectDisposedException">닫은 뒤에 불렀다.</exception>
/// <exception cref="InvalidDataException">모델이 0~1 밖이나 유한하지 않은 값을 냈다(Android 의 require 와 같다).</exception>
public float Score(ReadOnlySpan<float> block)
{
ObjectDisposedException.ThrowIf(_disposed, this);
Expand All @@ -157,11 +161,16 @@ public float Score(ReadOnlySpan<float> block)
float probability = _output[0];
_nextState.CopyTo(_state, 0);
block[(BlockSize - ContextSize)..].CopyTo(_contextTail);
if (!float.IsFinite(probability) || probability < 0f || probability > 1f)
throw new InvalidDataException($"Silero returned an invalid speech probability {probability}");
return probability;
}

/// <summary>
/// <see cref="Score"/> 가 낸 값이 확률로 쓸 만한지(유한하고 0~1 안). 아니면 그 값을 낸 순환 상태도 믿을 수 없으므로
/// 부르는 쪽이 <see cref="Reset"/> 하고 그 덩어리를 증거로 세지 않는다.
/// </summary>
public static bool IsValidProbability(float probability) =>
float.IsFinite(probability) && probability >= 0f && probability <= 1f;

/// <summary>세션과 버퍼를 놓는다. 여러 번 불러도 된다. <see cref="Score"/> 와 겹쳐 부르면 안 된다(같은 스레드에서만 닫는다).</summary>
public void Dispose()
{
Expand Down Expand Up @@ -218,7 +227,10 @@ internal interface ISileroModel : IDisposable
/// <inheritdoc cref="SileroInference.Reset"/>
void Reset();

/// <inheritdoc cref="SileroInference.Score"/>
/// <summary>
/// 16kHz 512샘플로 말소리일 확률(0~1)을 낸다. 깨진 값(유한하지 않거나 0~1 밖)은 던지지 않고 그대로 돌려준다
/// (<see cref="SileroInference.IsValidProbability"/>). 던지는 것은 모델을 더 쓸 수 없는 실패뿐이고, 그때는 미리보기가 꺼진다.
/// </summary>
float Score(ReadOnlySpan<float> block);
}

Expand Down
28 changes: 26 additions & 2 deletions SoundVisualizer.Core/Ai/SileroStreamWorker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ namespace SoundVisualizer.Ai
/// 모델 상태를 비우고 <c>discontinuity</c> 를 부른다. 정규 추론은 이 스레드를 기다리지 않는다.
/// - 링의 세대가 바뀌면(캡처를 다시 시작) 처음부터 다시 센다. 덩어리를 본 뒤에도 세대가 그대로일 때만 결과를 넘긴다.
/// - 모델은 이 스레드가 만들고 쓰고 닫는다. 만들거나 돌리다 실패하면 미리보기만 꺼지고(<see cref="Failed"/>) 정규 분류는 그대로 돈다.
/// - 모델이 깨진 값(유한하지 않거나 0~1 밖)을 내는 것은 실패가 아니다. 상태를 비우고 <c>discontinuity</c> 를 부른 뒤 다음 덩어리부터
/// 이어 간다(<see cref="InvalidScores"/>). 예전에는 이것도 실패로 쳐서, 값 하나 때문에 캡처를 다시 시작할 때까지 미리보기가 꺼졌다.
///
/// PC 에서 바꾼 것: 닫을 때 스레드가 끝나기를 기다린다(<see cref="Join"/>, 파이프라인이 1초까지). Android 는 기다리지 않고 스레드가
/// 스스로 끝나며 세션을 놓게 둔다. 여기서도 기다리다 시간이 다 되면 그렇게 둔다.
Expand Down Expand Up @@ -44,11 +46,12 @@ internal sealed class SileroStreamWorker : IDisposable
private int _failed;
private long _calls;
private long _resets;
private long _invalidScores;
private long _next;

/// <param name="buffer">읽을 AI 링. 레이트는 만들 때 정해진다.</param>
/// <param name="modelFactory">이 스레드 안에서 한 번 부른다. 만든 모델은 이 스레드가 닫는다.</param>
/// <param name="discontinuity">밀린 것을 버리고 건너뛸 때 이 스레드에서 부른다. 걸린 후보를 비우는 데 쓴다.</param>
/// <param name="discontinuity">밀린 것을 버리고 건너뛸 때와 모델이 깨진 값을 냈을 때 이 스레드에서 부른다. 걸린 후보를 비우는 데 쓴다.</param>
/// <param name="block">덩어리 하나를 본 뒤 이 스레드에서 부른다. 오래 붙잡지 않는다.</param>
/// <param name="nanoClock">단조 나노초. 덩어리를 본 시각을 잰다.</param>
public SileroStreamWorker(
Expand Down Expand Up @@ -77,9 +80,12 @@ public SileroStreamWorker(
/// <summary>Silero 를 돌린 덩어리 수(테스트와 진단용).</summary>
public long Calls => Interlocked.Read(ref _calls);

/// <summary>모델 상태를 비운 횟수. 세대가 바뀌거나(처음 한 번 포함) 밀린 것을 버릴 때 센다.</summary>
/// <summary>모델 상태를 비운 횟수. 세대가 바뀌거나(처음 한 번 포함) 밀린 것을 버릴 때, 모델이 깨진 값을 냈을 때 센다.</summary>
public long Resets => Interlocked.Read(ref _resets);

/// <summary>모델이 깨진 값(유한하지 않거나 0~1 밖)을 낸 덩어리 수. 그 덩어리는 증거로 넘기지 않는다(테스트와 진단용).</summary>
public long InvalidScores => Interlocked.Read(ref _invalidScores);

/// <summary>모델을 만들거나 돌리다 실패해 미리보기를 껐는지. 정규 분류는 상관없이 돈다.</summary>
public bool Failed => Volatile.Read(ref _failed) != 0;

Expand Down Expand Up @@ -167,6 +173,24 @@ private void Run()
BeforeScoreForTest?.Invoke();
float probability = model.Score(output);
Interlocked.Increment(ref _calls);
if (!SileroInference.IsValidProbability(probability))
{
// 깨진 값이 한 번 나오면 이어받는 순환 상태도 믿을 수 없다. 그렇다고 스레드를 끝내면 캡처를 다시 시작할 때까지
// 미리보기가 조용히 꺼진다. 밀린 것을 버릴 때처럼 상태를 비우고 모아 둔 증거(후보)를 버린 뒤, 이 덩어리는 건너뛰고
// 다음 덩어리부터 이어 간다(Android #456). 값이 계속 깨져 나와도 미리보기가 받아들여지지 않을 뿐이다.
model.Reset();
Interlocked.Increment(ref _resets);
// 값이 계속 깨져 나오면 덩어리마다(초당 31번) 오므로 처음 한 번만 남긴다.
if (Interlocked.Increment(ref _invalidScores) == 1)
{
AppLog.Warn(Tag, string.Create(System.Globalization.CultureInfo.InvariantCulture,
$"Silero returned an invalid speech probability {probability}; state reset, preview continues"));
}
_discontinuity();
next += SileroInference.BlockSize;
Interlocked.Exchange(ref _next, next);
continue;
}
// 보는 사이 캡처를 다시 시작했으면 옛 세대의 결과는 넘기지 않는다.
if (_buffer.Cursor().Generation == epoch && !IsClosed)
{
Expand Down
Loading
Loading