Android 작업 알림을 누르면 작업 현황 화면, 위쪽 작업 표시에 진행률 · 누르면 작업 현황, 앱 화면은 하나만 (singleTask). AI 음성인식 중 취소가 바로 먹히게 (whisper abort, Android 태그 포인터)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
RyuJongJin 2026-10-04 20:18:27 +09:00
parent 9dea4b3e5e
commit ad01fd5671
14 changed files with 246 additions and 19 deletions

View File

@ -24,7 +24,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:launchMode="singleTask"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"

View File

@ -77,7 +77,10 @@ class KeepAliveService : Service() {
}
val open = PendingIntent.getActivity(
context, 0,
Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
// 누르면 앱의 "작업 현황" 화면으로 (지금 하는 작업 · 진행률 · 대기열 · 다운로드)
Intent(context, MainActivity::class.java)
.setAction(MainActivity.ACTION_SHOW_JOBS)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val b = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {

View File

@ -17,14 +17,23 @@ import java.io.File
/// 저장소 전체 접근 (동영상 옆 jj_mkv 폴더에 MKV 를 만들기 위해) 확인 · 요청, 다운로드 프로그램 준비
class MainActivity : FlutterActivity() {
companion object {
/// 작업 알림을 눌렀을 때: 작업 현황 화면을 연다
const val ACTION_SHOW_JOBS = "com.jj.jj_mkvmaker.SHOW_JOBS"
}
private var channel: MethodChannel? = null
/// 앱이 켜질 때 작업 알림으로 열렸는지 (Dart 가 준비되면 가져간다)
private var pendingShowJobs = false
/// 다른 앱에서 연 동영상 (앱이 켜질 때 받은 것: Dart 가 준비되면 가져간다)
private var pendingOpen: List<Map<String, String?>> = emptyList()
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
pendingOpen = openedFiles(intent)
pendingShowJobs = intent?.action == ACTION_SHOW_JOBS
val ch = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jj_mkvmaker/android")
channel = ch
ch.setMethodCallHandler { call, result ->
@ -33,6 +42,10 @@ class MainActivity : FlutterActivity() {
result.success(pendingOpen)
pendingOpen = emptyList()
}
"takeShowJobs" -> {
result.success(pendingShowJobs)
pendingShowJobs = false
}
"hasAllFilesAccess" -> result.success(hasAllFilesAccess())
"requestAllFilesAccess" -> {
requestAllFilesAccess()
@ -98,6 +111,10 @@ class MainActivity : FlutterActivity() {
/// 앱이 켜져 있을 때 다른 앱에서 동영상을 열면 (launchMode singleTop)
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
if (intent.action == ACTION_SHOW_JOBS) {
channel?.invokeMethod("showJobs", null)
return
}
val files = openedFiles(intent)
if (files.isNotEmpty()) channel?.invokeMethod("openFiles", files)
}

View File

@ -0,0 +1,39 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/core/languages.dart';
import 'package:jj_mkvmaker/core/models.dart';
import 'package:jj_mkvmaker/services/model_store.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
/// 긴 영상 (JJ_LONG_VIDEO) 의 음성인식 도중 [작업 취소] → 몇 초 안에 멈춘다 (끝까지 기다리지 않음)
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('음성인식 중 취소', (tester) async {
final video = Platform.environment['JJ_LONG_VIDEO'];
if (video == null || !File(video).existsSync()) return markTestSkipped('JJ_LONG_VIDEO 없음');
final c = AppController(PlatformServices.create());
await tester.runAsync(() async {
await c.init();
await c.addVideos([video]);
final v = c.videos.single;
final job = c.generateAiSubtitles([v], AiOptions(targets: {languageOf('en')}, whisper: whisperModels.first));
// 음성인식이 시작될 때까지 (음성 추출이 끝나고)
for (var i = 0; i < 600 && !(v.phase ?? '').startsWith('음성인식'); i++) {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
await Future<void>.delayed(const Duration(seconds: 5));
final sw = Stopwatch()..start();
c.cancel();
await job;
// ignore: avoid_print
print('RESULT 취소 후 멈추기까지 ${sw.elapsedMilliseconds}ms · 상태 ${v.status} · ${c.logs.last}');
expect(sw.elapsed.inSeconds, lessThan(15));
expect(v.status, JobStatus.ready);
expect(c.logs.last, contains('취소'));
});
}, timeout: const Timeout(Duration(minutes: 10)));
}

View File

@ -825,7 +825,9 @@ class AppController extends ChangeNotifier {
// 3. 음성인식 (5% ~ 50%)
phase(tr('음성인식 중'), 0.05);
final raw = await services.createRecognizer!().transcribe(
final recognizer = _recognizer = services.createRecognizer!();
if (_aiCancelled) throw const AiCancelled();
final raw = await recognizer.transcribe(
wav,
modelPath: await services.models.pathOf(opts.whisper),
language: opts.source == undetermined ? 'auto' : whisperCode(opts.source),
@ -1279,7 +1281,11 @@ class AppController extends ChangeNotifier {
static String get missingFileMessage => tr('동영상 파일이 없습니다 (옮겨졌거나 지워졌습니다). 목록에서 빼고 다시 추가하세요.');
/// 지금 작업 중단 + 대기 중인 작업 모두 비우기
/// 지금 음성인식 (취소하면 멈춘다)
SpeechRecognizer? _recognizer;
void cancel() {
_recognizer?.cancel();
if (pendingJobs.isNotEmpty) _log(trf('대기 중인 작업 {0}개 취소', [pendingJobs.length]));
pendingJobs.clear();
for (final (_, done) in _pendingRuns) {

View File

@ -38,6 +38,7 @@ import 'ui/player_page.dart';
import 'ui/setup_dialog.dart';
import 'ui/update_dialog.dart';
import 'ui/theme.dart';
import 'ui/work_panel.dart';
import 'l10n/tr.dart';
import 'app/i18n_controller.dart';
@ -408,6 +409,12 @@ Future<void> runAndroid(String dataDir) async {
// 연결 프로그램 · 공유로 받은 동영상: 앱이 켜져 있을 때 (MainActivity.onNewIntent)
const android = MethodChannel('jj_mkvmaker/android');
android.setMethodCallHandler((call) async {
if (call.method == 'showJobs') {
// 작업 알림을 눌렀다: 작업 현황 화면으로
final nav = navigatorKey.currentState;
if (nav != null) unawaited(WorkStatusPage.open(nav, controller, downloads));
return null;
}
if (call.method == 'openFiles') {
await openFromOtherApp(navigatorKey, controller, (call.arguments as List).cast<Map<Object?, Object?>>());
}
@ -417,6 +424,10 @@ Future<void> runAndroid(String dataDir) async {
WidgetsBinding.instance.addPostFrameCallback((_) async {
// 연결 프로그램 · 공유로 앱을 켠 경우: 그 동영상부터
try {
if (await android.invokeMethod<bool>('takeShowJobs') ?? false) {
final nav = navigatorKey.currentState;
if (nav != null) unawaited(WorkStatusPage.open(nav, controller, downloads));
}
final opened = await android.invokeMethod<List<Object?>>('takeOpenedFiles') ?? const [];
if (opened.isNotEmpty) {
unawaited(openFromOtherApp(navigatorKey, controller, opened.cast<Map<Object?, Object?>>()));

View File

@ -1,6 +1,8 @@
import 'dart:ffi' as ffi;
import 'dart:io';
import 'dart:math' as math;
import 'package:ffi/ffi.dart';
import 'package:whisper_ggml/whisper_ggml.dart';
import '../../core/srt.dart';
@ -8,6 +10,15 @@ import '../../services/ai_services.dart';
/// whisper.cpp 음성인식. Windows·Android 공용.
class WhisperRecognizer implements SpeechRecognizer {
/// 지금 음성인식의 중단 신호 (네이티브가 읽는 int32). 0 이 아니면 멈춘다.
ffi.Pointer<ffi.Int32>? _abort;
@override
void cancel() {
final a = _abort;
if (a != null) a.value = 1;
}
@override
Future<List<Cue>> transcribe(
String wavPath, {
@ -17,19 +28,31 @@ class WhisperRecognizer implements SpeechRecognizer {
}) async {
// 실제 코어 수 (하이퍼스레딩 절반) 가 가장 빠름
final threads = math.max(1, math.min(8, Platform.numberOfProcessors ~/ 2));
final res = await const Whisper(model: WhisperModel.base).transcribe(
transcribeRequest: TranscribeRequest(
audio: wavPath,
language: language,
threads: threads,
suppressNonSpeechTokens: true,
),
modelPath: modelPath,
onProgress: onProgress == null ? null : (pct) => onProgress(pct / 100),
);
return [
for (final s in res.segments ?? const <WhisperTranscribeSegment>[])
Cue(s.fromTs, s.toTs, s.text),
];
final abort = _abort = calloc<ffi.Int32>();
try {
final res = await const Whisper(model: WhisperModel.base).transcribe(
transcribeRequest: TranscribeRequest(
audio: wavPath,
language: language,
threads: threads,
suppressNonSpeechTokens: true,
),
modelPath: modelPath,
onProgress: onProgress == null ? null : (pct) => onProgress(pct / 100),
abortFlagAddress: abort.address,
);
if (abort.value != 0) throw const AiCancelled();
return [
for (final s in res.segments ?? const <WhisperTranscribeSegment>[])
Cue(s.fromTs, s.toTs, s.text),
];
} catch (e) {
// 취소로 멈춘 것이면 "취소" 로 (네이티브는 "failed to process audio" 오류를 낸다)
if (abort.value != 0) throw const AiCancelled();
rethrow;
} finally {
_abort = null;
calloc.free(abort);
}
}
}

View File

@ -19,6 +19,9 @@ abstract class SpeechRecognizer {
String language = 'auto',
AiProgress? onProgress,
});
/// 진행 중인 음성인식 멈추기 (transcribe 가 곧 오류로 끝난다)
void cancel();
}
/// 번역 경계. 구현: platform/common/nllb_translator.dart (Windows·Android 공용)

View File

@ -4,10 +4,83 @@ import '../app/app_controller.dart';
import '../app/download_manager.dart';
import '../core/models.dart';
import '../services/downloader.dart';
import 'app_actions.dart';
import 'downloads_page.dart';
import 'theme.dart';
import '../l10n/tr.dart';
/// "작업 현황" 화면 (전체 화면): Android 작업 알림 · 위쪽 작업 표시를 누르면 연다
class WorkStatusPage extends StatefulWidget {
final AppController c;
final DownloadManager? downloads;
const WorkStatusPage({super.key, required this.c, this.downloads});
/// 열려 있는 작업 현황 화면 수
static int _open = 0;
@override
State<WorkStatusPage> createState() => _WorkStatusPageState();
static const routeName = 'jobs';
/// 작업 현황으로: 이미 열려 있으면 그 화면으로, 없으면 새로
static Future<void> open(NavigatorState nav, AppController c, DownloadManager? downloads) async {
if (_open > 0) {
var found = false;
nav.popUntil((r) {
if (r.settings.name == routeName) found = true;
return found || r.isFirst;
});
if (found) return;
}
await nav.push(MaterialPageRoute<void>(
settings: const RouteSettings(name: routeName),
builder: (_) => WorkStatusPage(c: c, downloads: downloads),
));
}
}
class _WorkStatusPageState extends State<WorkStatusPage> {
@override
void initState() {
super.initState();
WorkStatusPage._open++;
}
@override
void dispose() {
WorkStatusPage._open--;
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
body: Column(children: [
Container(
height: appBarHeight,
color: JjColors.panel,
padding: const EdgeInsets.only(left: 8, right: appBarRightPadding),
child: Row(children: [
const AppNavButtons(),
const SizedBox(width: 8),
Text(tr('작업 현황'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const Spacer(),
AppActions(c: widget.c),
]),
),
Expanded(
child: WorkPanel(
c: widget.c,
downloads: widget.downloads,
onClose: () => Navigator.maybePop(context),
onOpenHome: () => AppNavButtons.toMkv(context),
),
),
]),
);
}
/// 브라우저 오른쪽 "작업 현황" (화면 분할): 자막 · MKV 작업과 다운로드 진행을 보면서 인터넷을 볼 수 있게.
class WorkPanel extends StatelessWidget {
final AppController c;
@ -308,7 +381,10 @@ class JobIndicator extends StatelessWidget {
: running.map((v) => v.progress).reduce((a, b) => a + b) /
running.length;
final wait = c.pendingJobs.length;
return Tooltip(
// 누르면 작업 현황 화면 (어느 화면에서나)
final scope = AppScope.maybeOf(context);
final nav = Navigator.maybeOf(context);
final body = Tooltip(
message: [
trf('지금: {0}', [c.currentJob ?? '']),
for (final v in running)
@ -327,6 +403,11 @@ class JobIndicator extends StatelessWidget {
value: p == null || p == 0 ? null : p,
),
),
// 좁아서 아이콘만일 때도 진행률은 보이게
if (iconOnly && p != null) ...[
const SizedBox(width: 4),
Text('${(p * 100).round()}%', style: const TextStyle(fontSize: 12, color: JjColors.accent)),
],
if (!iconOnly) const SizedBox(width: 6),
if (!iconOnly)
ConstrainedBox(
@ -341,5 +422,11 @@ class JobIndicator extends StatelessWidget {
],
),
);
if (scope == null || nav == null) return body;
return InkWell(
borderRadius: BorderRadius.circular(6),
onTap: () => WorkStatusPage.open(nav, scope.controller, scope.downloads),
child: Padding(padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), child: body),
);
}
}

View File

@ -19,3 +19,8 @@ Windows 에서 음성인식이 끝나는 순간 앱이 종료됨 (예외 코드
- pubspec.yaml · lib/src/whisper_audio_convert.dart: `ffmpeg_kit_flutter_new_min` → `ffmpeg_kit_flutter_new_min_gpl`
(앱이 MKV 만들기에 쓰는 것과 같은 것. 두 가지가 함께 들어가면 같은 이름의 Java 클래스 · 네이티브 라이브러리가 겹쳐 빌드 · 실행이 안 됨)
- android/build.gradle: compileSdk 34 → 36 (ffmpeg-kit min-gpl 이 35 이상을 요구)
## 음성인식 취소 (JJ_MKVMaker)
- android/src/whisper/main.cpp: 요청 JSON 의 `abort_flag` (Dart 가 가진 int32 의 주소) 를 `whisper_full_params.abort_callback` 에 연결. 값이 0 이 아니면 whisper 가 다음 계산 단계에서 멈춘다.
Android arm64 의 힙 주소는 맨 위 바이트에 태그가 있어 Dart int 로는 음수 → 부호 있는 정수도 받는다.
- lib/src/whisper.dart: `transcribe(abortFlagAddress:)` → 요청 JSON 에 `abort_flag` 추가.

View File

@ -74,6 +74,9 @@ struct whisper_params
// Address of a Dart NativeCallable<Void Function(Int32)>; 0 = none.
uint64_t progress_cb_addr = 0;
// JJ: address of an int32 cancel flag owned by Dart; non-zero = abort. 0 = none.
uint64_t abort_flag_addr = 0;
// Park the loaded model in g_model_cache after this request instead of
// freeing it, so the next request with the same model file skips the
// multi-second load (issue #26). Off = load-per-request, as always.
@ -166,6 +169,14 @@ json transcribe(json jsonBody) noexcept
{
params.progress_cb_addr = jsonBody["progress_callback"].get<uint64_t>();
}
// JJ: Android arm64 heap pointers carry a tag in the top byte (0xb4…), so Dart's int
// for the address is negative — accept signed numbers too and reinterpret the bits.
if (jsonBody.contains("abort_flag") && jsonBody["abort_flag"].is_number_integer())
{
params.abort_flag_addr = jsonBody["abort_flag"].is_number_unsigned()
? jsonBody["abort_flag"].get<uint64_t>()
: (uint64_t)jsonBody["abort_flag"].get<int64_t>();
}
if (jsonBody.contains("keep_model_loaded") && jsonBody["keep_model_loaded"].is_boolean())
{
params.keep_model_loaded = jsonBody["keep_model_loaded"].get<bool>();
@ -351,6 +362,14 @@ json transcribe(json jsonBody) noexcept
wparams.progress_callback_user_data = (void *)(uintptr_t)params.progress_cb_addr;
}
if (params.abort_flag_addr) {
// JJ: the user cancelled — whisper checks this between encoder/decoder steps
wparams.abort_callback = [](void * data) {
return *(volatile int32_t *)data != 0;
};
wparams.abort_callback_user_data = (void *)(uintptr_t)params.abort_flag_addr;
}
if (whisper_full(ctx, wparams, pcmf32.data(), pcmf32.size()) != 0)
{
jsonResult["@type"] = "error";

View File

@ -54,10 +54,17 @@ class Whisper {
Future<Map<String, dynamic>> _request({
required WhisperRequestDto whisperRequest,
Map<String, Object?>? extra,
}) async {
return Isolate.run(() async {
final Pointer<Utf8> data =
whisperRequest.toRequestString().toNativeUtf8();
(extra == null
? whisperRequest.toRequestString()
: json.encode({
...json.decode(whisperRequest.toRequestString()) as Map<String, dynamic>,
...extra,
}))
.toNativeUtf8();
final DynamicLibrary lib = _openLib();
final Pointer<Utf8> res =
lib.lookupFunction<WReqNative, WReqNative>('request').call(data);
@ -80,6 +87,8 @@ class Whisper {
required TranscribeRequest transcribeRequest,
required String modelPath,
void Function(int percent)? onProgress,
// JJ: address of an int32 flag; set it to non-zero to stop recognition early
int? abortFlagAddress,
}) async {
// A listener callable may be invoked from whisper's worker thread;
// it delivers to this isolate. Kept open until the request finishes.
@ -105,6 +114,7 @@ class Whisper {
modelPath,
progressCallbackAddress: progressCallable?.nativeFunction.address,
),
extra: abortFlagAddress == null ? null : {'abort_flag': abortFlagAddress},
);
if (result['text'] == null) {

View File

@ -16,6 +16,8 @@ import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:path/path.dart' as p;
class _FakeRecognizer implements SpeechRecognizer {
@override
void cancel() {}
String? lastLanguage;
@override
Future<List<Cue>> transcribe(String wavPath,

View File

@ -24,6 +24,8 @@ import 'package:jj_mkvmaker/ui/settings_page.dart';
import 'package:path/path.dart' as p;
class _Recognizer implements SpeechRecognizer {
@override
void cancel() {}
final heard = <String>[];
@override
Future<List<Cue>> transcribe(String wavPath,