From 901bf58c5cf9151942f8c4cd4e1427a02dec4b52 Mon Sep 17 00:00:00 2001 From: Tom Cooks Date: Sat, 10 Oct 2026 15:42:04 -0400 Subject: RECCoon 1.0.0: rebrand to Tom Cooks, Parakeet-only, GPLv3, F-Droid-ready --- .gitignore | 5 +- LICENSE | 674 +++++++++++++++ README.md | 102 ++- TODO.md | 113 +++ app/build.gradle.kts | 19 +- app/src/main/AndroidManifest.xml | 30 +- .../java/com/tomcooks/reccoon/AudioCompressor.java | 139 +++ .../main/java/com/tomcooks/reccoon/Formats.java | 67 ++ .../java/com/tomcooks/reccoon/InsetsHelper.java | 33 + .../java/com/tomcooks/reccoon/LevelMeterView.java | 160 ++++ .../java/com/tomcooks/reccoon/LiveMonitor.java | 189 +++++ .../java/com/tomcooks/reccoon/MainActivity.java | 933 +++++++++++++++++++++ app/src/main/java/com/tomcooks/reccoon/Marker.java | 19 + .../java/com/tomcooks/reccoon/MarkerStore.java | 110 +++ .../com/tomcooks/reccoon/ModelDownloadService.java | 213 +++++ .../java/com/tomcooks/reccoon/ModelRepository.java | 367 ++++++++ .../java/com/tomcooks/reccoon/PcmAudioSource.java | 239 ++++++ .../java/com/tomcooks/reccoon/PlayerActivity.java | 647 ++++++++++++++ .../com/tomcooks/reccoon/RecordingActions.java | 154 ++++ .../com/tomcooks/reccoon/RecordingService.java | 68 ++ .../java/com/tomcooks/reccoon/RecordingStore.java | 128 +++ .../com/tomcooks/reccoon/StreamingResampler.java | 57 ++ .../java/com/tomcooks/reccoon/TranscriptStore.java | 129 +++ .../com/tomcooks/reccoon/TranscriptionEngine.java | 413 +++++++++ .../main/java/com/tomcooks/reccoon/WavMarkers.java | 121 +++ .../java/com/tomcooks/reccoon/WavRecorder.java | 542 ++++++++++++ .../java/com/tomcooks/reccoon/WaveformView.java | 94 +++ app/src/main/java/com/wuhei/reccoon/Formats.java | 67 -- .../main/java/com/wuhei/reccoon/InsetsHelper.java | 33 - .../java/com/wuhei/reccoon/LevelMeterView.java | 160 ---- .../main/java/com/wuhei/reccoon/MainActivity.java | 522 ------------ app/src/main/java/com/wuhei/reccoon/Marker.java | 19 - .../main/java/com/wuhei/reccoon/MarkerStore.java | 110 --- .../java/com/wuhei/reccoon/ModelRepository.java | 206 ----- .../java/com/wuhei/reccoon/PlayerActivity.java | 628 -------------- .../java/com/wuhei/reccoon/RecordingActions.java | 149 ---- .../java/com/wuhei/reccoon/RecordingService.java | 68 -- .../java/com/wuhei/reccoon/RecordingStore.java | 108 --- .../java/com/wuhei/reccoon/StreamingResampler.java | 57 -- .../java/com/wuhei/reccoon/TranscriptStore.java | 129 --- .../com/wuhei/reccoon/TranscriptionEngine.java | 418 --------- .../main/java/com/wuhei/reccoon/WavMarkers.java | 121 --- .../main/java/com/wuhei/reccoon/WavRecorder.java | 407 --------- .../main/java/com/wuhei/reccoon/WaveformView.java | 94 --- app/src/main/res/layout/activity_main.xml | 111 ++- app/src/main/res/layout/activity_player.xml | 20 +- app/src/main/res/values/strings.xml | 32 +- .../java/com/tomcooks/reccoon/FormatsTest.java | 20 + .../tomcooks/reccoon/StreamingResamplerTest.java | 41 + .../com/wuhei/reccoon/StreamingResamplerTest.java | 41 - settings.gradle.kts | 2 + tools/download-parakeet.sh | 33 + tools/whistle-eval/.gitignore | 7 + tools/whistle-eval/README.md | 94 +++ tools/whistle-eval/build-data.mjs | 19 + tools/whistle-eval/evaluate.py | 141 ++++ tools/whistle-eval/package-lock.json | 229 +++++ tools/whistle-eval/package.json | 14 + tools/whistle-eval/parakeet_baseline.py | 67 ++ tools/whistle-eval/samples/README.md | 41 + tools/whistle-eval/samples/en1.txt | 1 + tools/whistle-eval/samples/en2.txt | 1 + tools/whistle-eval/samples/it1.txt | 1 + tools/whistle-eval/samples/it1_noisy.txt | 1 + tools/whistle-eval/samples/it2.txt | 1 + tools/whistle-eval/samples/jfk.txt | 1 + tools/whistle-eval/setup.sh | 38 + tools/whistle-eval/whisper_baseline.py | 65 ++ 68 files changed, 6670 insertions(+), 3412 deletions(-) create mode 100644 LICENSE create mode 100644 app/src/main/java/com/tomcooks/reccoon/AudioCompressor.java create mode 100644 app/src/main/java/com/tomcooks/reccoon/Formats.java create mode 100644 app/src/main/java/com/tomcooks/reccoon/InsetsHelper.java create mode 100644 app/src/main/java/com/tomcooks/reccoon/LevelMeterView.java create mode 100644 app/src/main/java/com/tomcooks/reccoon/LiveMonitor.java create mode 100644 app/src/main/java/com/tomcooks/reccoon/MainActivity.java create mode 100644 app/src/main/java/com/tomcooks/reccoon/Marker.java create mode 100644 app/src/main/java/com/tomcooks/reccoon/MarkerStore.java create mode 100644 app/src/main/java/com/tomcooks/reccoon/ModelDownloadService.java create mode 100644 app/src/main/java/com/tomcooks/reccoon/ModelRepository.java create mode 100644 app/src/main/java/com/tomcooks/reccoon/PcmAudioSource.java create mode 100644 app/src/main/java/com/tomcooks/reccoon/PlayerActivity.java create mode 100644 app/src/main/java/com/tomcooks/reccoon/RecordingActions.java create mode 100644 app/src/main/java/com/tomcooks/reccoon/RecordingService.java create mode 100644 app/src/main/java/com/tomcooks/reccoon/RecordingStore.java create mode 100644 app/src/main/java/com/tomcooks/reccoon/StreamingResampler.java create mode 100644 app/src/main/java/com/tomcooks/reccoon/TranscriptStore.java create mode 100644 app/src/main/java/com/tomcooks/reccoon/TranscriptionEngine.java create mode 100644 app/src/main/java/com/tomcooks/reccoon/WavMarkers.java create mode 100644 app/src/main/java/com/tomcooks/reccoon/WavRecorder.java create mode 100644 app/src/main/java/com/tomcooks/reccoon/WaveformView.java delete mode 100644 app/src/main/java/com/wuhei/reccoon/Formats.java delete mode 100644 app/src/main/java/com/wuhei/reccoon/InsetsHelper.java delete mode 100644 app/src/main/java/com/wuhei/reccoon/LevelMeterView.java delete mode 100644 app/src/main/java/com/wuhei/reccoon/MainActivity.java delete mode 100644 app/src/main/java/com/wuhei/reccoon/Marker.java delete mode 100644 app/src/main/java/com/wuhei/reccoon/MarkerStore.java delete mode 100644 app/src/main/java/com/wuhei/reccoon/ModelRepository.java delete mode 100644 app/src/main/java/com/wuhei/reccoon/PlayerActivity.java delete mode 100644 app/src/main/java/com/wuhei/reccoon/RecordingActions.java delete mode 100644 app/src/main/java/com/wuhei/reccoon/RecordingService.java delete mode 100644 app/src/main/java/com/wuhei/reccoon/RecordingStore.java delete mode 100644 app/src/main/java/com/wuhei/reccoon/StreamingResampler.java delete mode 100644 app/src/main/java/com/wuhei/reccoon/TranscriptStore.java delete mode 100644 app/src/main/java/com/wuhei/reccoon/TranscriptionEngine.java delete mode 100644 app/src/main/java/com/wuhei/reccoon/WavMarkers.java delete mode 100644 app/src/main/java/com/wuhei/reccoon/WavRecorder.java delete mode 100644 app/src/main/java/com/wuhei/reccoon/WaveformView.java create mode 100644 app/src/test/java/com/tomcooks/reccoon/FormatsTest.java create mode 100644 app/src/test/java/com/tomcooks/reccoon/StreamingResamplerTest.java delete mode 100644 app/src/test/java/com/wuhei/reccoon/StreamingResamplerTest.java create mode 100755 tools/download-parakeet.sh create mode 100644 tools/whistle-eval/.gitignore create mode 100644 tools/whistle-eval/README.md create mode 100644 tools/whistle-eval/build-data.mjs create mode 100644 tools/whistle-eval/evaluate.py create mode 100644 tools/whistle-eval/package-lock.json create mode 100644 tools/whistle-eval/package.json create mode 100644 tools/whistle-eval/parakeet_baseline.py create mode 100644 tools/whistle-eval/samples/README.md create mode 100644 tools/whistle-eval/samples/en1.txt create mode 100644 tools/whistle-eval/samples/en2.txt create mode 100644 tools/whistle-eval/samples/it1.txt create mode 100644 tools/whistle-eval/samples/it1_noisy.txt create mode 100644 tools/whistle-eval/samples/it2.txt create mode 100644 tools/whistle-eval/samples/jfk.txt create mode 100755 tools/whistle-eval/setup.sh create mode 100644 tools/whistle-eval/whisper_baseline.py diff --git a/.gitignore b/.gitignore index 26a741d..7226cbf 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,6 @@ .externalNativeBuild .cxx -# Large prebuilt binaries — see TODO.md ("Build / distribution"). -# The sherpa-onnx AAR (~50 MB) and built APKs are not tracked in git yet. -/app/libs/*.aar +# Build outputs. The sherpa-onnx runtime is fetched from JitPack, so no +# prebuilt binary is committed here. /dist/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md index 8785d1c..812afd2 100644 --- a/README.md +++ b/README.md @@ -4,22 +4,34 @@ A minimal Android app that records uncompressed WAV audio and transcribes it on device. - **Format:** 44.1 kHz, 16-bit PCM (stereo when the device supports it), no compression +- **Compact mode:** an optional switch saves a small AAC-LC `.m4a` instead of the WAV + (~22 MB/hour mono instead of ~317 MB); transcription still works because the + decoder streams the M4A back to PCM (`PcmAudioSource`) - **Controls:** one big round button — tap to start recording (timer starts), tap again to stop and save - **Meters:** live scrolling waveform plus left/right peak level meters with a clip indicator while recording - **Mono switch:** optionally record a single channel (half the file size) +- **Swap L/R:** exchange the stereo channels when a device reports them inverted in + landscape; applies to the meters and the saved file +- **Mic picker:** choose Auto, the built-in mic, or a specific input (Bluetooth SCO, + USB, wired headset ...) — the BT-Mic-Force feature folded into RECCoon +- **Live monitoring:** an optional **Monitor** switch plays the microphone through a + chosen output (built-in speaker, wired, USB, Bluetooth ...). Use headphones — the + speaker causes feedback. A dedicated thread and a bounded queue keep a slow output + from ever stalling the recording +- **Discard:** stop a recording and throw it away without saving - **Markers:** place labelled cue points while recording; they are embedded in the WAV (`cue ` + `LIST/adtl`) and shown in the player - **Silence:** optional Do Not Disturb total silence (no notifications, no vibration) while recording - **Recordings list:** scrollable list at the bottom of the main screen, newest first - **Player:** scrubber, ±10 s jumps, speed control, delete / rename / share / export, no autoplay - **Transcription:** on-device, offline speech recognition with [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) using multilingual - Whisper — works with **English and Italian** -- **Name:** RECCoon · **by** wuhei · **version** 1.4.0-alpha + **NVIDIA Parakeet v3** — works with English, Italian and 23 other languages +- **Name:** RECCoon · **by** Tom Cooks · **version** 1.0.0 - **Icon:** raccoon with a microphone ## Requirements -- Android 8.0 (API 26) or newer, arm64-v8a or x86_64 +- Android 8.0 (API 26) or newer, arm64-v8a - `RECORD_AUDIO` permission - On Android 9 and older, `WRITE_EXTERNAL_STORAGE` is also requested - Internet access the first time a transcription model is downloaded @@ -37,16 +49,19 @@ Outputs: - `app/build/outputs/apk/debug/app-debug.apk` - `app/build/outputs/apk/release/app-release.apk` -A prebuilt copy is available at `dist/RECCoon-1.4.0-alpha.apk`. Note that the -sherpa-onnx AAR in `app/libs/` and the APK in `dist/` are **not** tracked in -git because they are large; see `TODO.md`. +A prebuilt copy is available at `dist/RECCoon-1.0.0.apk` (release, signed with +the debug key). The APK in `dist/` is **not** tracked in git. The sherpa-onnx +runtime comes from [JitPack](https://jitpack.io) (an F-Droid-trusted Maven +repository), so no prebuilt binary lives in this repository. ## Recording -- `WavRecorder` uses `AudioRecord` (MIC source) to capture 16-bit PCM at - 44100 Hz into a temporary file, then rewrites a correct 44-byte RIFF/WAVE - header when recording stops. It uses stereo when available and falls back to - mono, and reports per-channel peak levels to the UI. +- `WavRecorder` uses `AudioRecord` (MIC source, or `VOICE_COMMUNICATION` for + Bluetooth SCO) to capture 16-bit PCM at 44100 Hz into a temporary file, then + rewrites a correct 44-byte RIFF/WAVE header when recording stops. It uses + stereo when available and falls back to mono, and reports per-channel peak + levels to the UI. A selected input device is applied with + `AudioRecord.setPreferredDevice`. - `RecordingService` is a microphone-typed foreground service started while recording, so capture continues when the app is in the background. - `MainActivity` handles the runtime permissions, the timer, the level meters @@ -79,32 +94,65 @@ git because they are large; see `TODO.md`. (MediaStore or file), **Export** (writes `.txt` and `.srt` to Downloads) and **Delete** (removes the WAV and its sidecars). - Transcript words are clickable: tapping a word seeks the player to it. Word - timings come from Whisper token timestamps when available, otherwise the + timings come from the model's token timestamps when available, otherwise the segment is divided between its words. ## On-device transcription -- `TranscriptionEngine` streams the WAV, resamples it to 16 kHz and segments it - with Silero VAD, then decodes each speech segment with a multilingual Whisper - model through sherpa-onnx. Streaming keeps memory bounded for long - recordings (offline Whisper only handles 30 s at a time). -- `ModelRepository` downloads the models on first use into app-private storage: +- `TranscriptionEngine` reads the recording through `PcmAudioSource`, which + parses WAV directly and decodes compressed files (AAC/M4A) with + `MediaExtractor` + `MediaCodec`, resamples to 16 kHz and segments the audio + with Silero VAD, then decodes each speech segment with the Parakeet v3 + transducer through sherpa-onnx. Streaming keeps memory bounded for long + recordings. +- `ModelRepository` downloads the model on first use: | Model | Size | Notes | |---|---|---| - | Whisper tiny (int8, multilingual) | ~99 MB | faster, lower accuracy | - | Whisper base (int8, multilingual) | ~154 MB | default, better Italian | - - Both include the small Silero VAD model (~0.6 MB). Files come from the - sherpa-onnx Whisper model repos on Hugging Face and the sherpa-onnx GitHub - release. -- Language can be forced to English (`en`) or Italian (`it`), or left on - **Auto detect**. + | Parakeet v3 (int8, 25 languages) | ~670 MB | only model, multilingual, large download | + + It includes the small Silero VAD model (~0.6 MB). Files come from the + sherpa-onnx model repo on Hugging Face and the sherpa-onnx GitHub release. + Models are stored in the app-specific external folder + `/sdcard/Android/data/com.tomcooks.reccoon/files/models/` so they can be + side-loaded with `adb push` or copied over USB instead of downloaded in-app. +- Parakeet v3 is a transducer, so it is loaded with `model_type="nemo_transducer"` + and handles language detection itself (no language picker). It needs ~670 MB of + storage and a large-heap device. - Transcripts are stored as JSON sidecars in app-private storage (`filesDir/transcripts/.json`). +## Compact (compressed) recordings + +- The **Compressed (small M4A)** checkbox transcodes the finished WAV to + AAC-LC (48 kbps per channel) with `AudioCompressor` and saves that instead. + Markers are kept as a JSON sidecar. Use it when storage matters more than + bit-exact audio. +- `tools/whistle-eval/` contains a desktop harness that compares Whisper, + Parakeet and Cactus Whistle; see its README for the results. + +## Live monitoring and discard + +- **Monitor** plays the incoming audio back through the selected output device while + recording. The output picker mirrors the mic picker (Auto, phone speaker, wired, + USB, Bluetooth). `LiveMonitor` owns an `AudioTrack` on its own thread with a small + bounded queue, so a slow output (for example Bluetooth A2DP) drops monitor chunks + instead of blocking the capture loop and corrupting the recording. Headphones are + strongly recommended — the phone speaker feeds back. +- **Discard** stops the recording and deletes the temporary file without saving it, + for takes that are obviously bad. + +## License + +RECCoon is free software, released under the **GNU General Public License, +version 3 or later** (see `LICENSE`). The Parakeet v3 model is +[NVIDIA Parakeet TDT 0.6B v3](https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3), +licensed CC-BY-4.0. + ## Native libraries and APK size -The APK bundles the sherpa-onnx/onnxruntime native libraries for `arm64-v8a` -and `x86_64`, so it is around 72 MB. Removing `x86_64` from the `abiFilters` -in `app/build.gradle.kts` roughly halves the APK size for phone-only builds. +The APK bundles the sherpa-onnx/onnxruntime native libraries only for +`arm64-v8a` (`abiFilters` in `app/build.gradle.kts`), which keeps it around +35–40 MB. Including `x86_64` as well would roughly double it; that is only +needed for x86 emulators. The ~670 MB Parakeet model is downloaded at runtime, +not bundled. diff --git a/TODO.md b/TODO.md index 1fa9671..76c4ea6 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,118 @@ # RECCoon TODO +## Requested (2026-10-05) + +### 1. Compare transcription models (Whisper / Parakeet / Whistle) + +#### 1a. Parakeet TDT 0.6B v3 — **done, integrated** + +- [x] **Test NVIDIA Parakeet v3** (`csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8`). + - [x] Added `parakeet_baseline.py` to `tools/whistle-eval/` and wired it into + `evaluate.py`. + - [x] Result (same six clips): average WER **3.7%** vs Whisper base 11.4% and + tiny 13.9%. Italian **2.8%** (vs base 16.7%) and **0%** on the noisy + Italian clip. The only errors are number formatting. + - [x] sherpa-onnx already supports it (`model_type="nemo_transducer"`), so no new + runtime is needed. + - [x] Integrated into the app: `ModelRepository.Model.PARAKEET_V3`, + `TranscriptionEngine` builds an `OfflineTransducerModelConfig`, and the + player model spinner lists "Parakeet v3 (best)". + - [ ] Verify decoding and memory use on a real phone; the int8 model is + ~670 MB (~652 MB encoder) and needs a large-heap device. + - [x] Removed the Whisper tiny/base options: Parakeet v3 is now the only model + (and therefore the default). The language picker was removed too, since + Parakeet handles language by itself. The `Model` enum keeps room for more + models later. + - [ ] Consider a smaller multilingual Parakeet/Canary variant if device RAM is tight. + +#### 1b. Cactus Whistle — tested, not integrated + +- [~] **Check whether Cactus Whistle transcribes better than multilingual Whisper.** + Reference: (ONNX export of + `Cactus-Compute/whistle`; supports en/de/fr/es/it/nl/pl). Motivation: Whisper + makes many mistakes, and a smaller model could replace the ~50 MB sherpa-onnx + AAR and fix the git push problem. + - [x] Build a desktop evaluation harness under `tools/whistle-eval/` (`setup.sh`, + `evaluate.py`, `whisper_baseline.py`, `build-data.mjs`). + - [x] Baseline + metric: the harness runs Whisper tiny/base through the same + sherpa-onnx 1.13.8 version the app bundles and reports WER. + - [x] First result (2026-10-05, six TTS clips, EN + IT): Whistle is **competitive + but not a clear win** — average WER 11.8% vs Whisper base 11.4% and tiny + 13.9%. On Italian Whistle was best (16.1% vs base 16.7%, tiny 23.2%); on + English tiny was best. See `tools/whistle-eval/README.md`. + - [ ] Decide the Android port after running the harness on **real RECCoon + recordings** — the TTS result is too close to call. + - [x] Feasibility: Whistle is a custom *Needle* encoder/decoder, **not** Whisper, so + sherpa-onnx cannot load it. An Android port needs `onnxruntime-android` plus + the mel filterbank, BPE tokenizer and engram features from `js/` / + `whistle.pack`. + - [x] Distribution: `whistle.pack` is ~17 MB (fits GitHub); the fp32 ONNX data is + ~145 MB. `build-data.mjs` reconstructs the `.onnx.data` files from the pack + byte-exactly, so only the pack needs to ship. + - [x] Decision: **do not add Whistle to the model picker** — Parakeet v3 is both + more accurate and supported by sherpa-onnx, so the custom-architecture port + is not worth it. + +### 2. Compact (compressed) recordings + +- [~] **Switch on the main screen to save very small compressed files instead of WAV.** + - [x] Codec: AAC-LC in MP4 (`.m4a`) for universal `MediaCodec` support; bitrate is + 48 kbps per channel (`AudioCompressor`). + - [x] Added a **Compressed (small M4A)** checkbox under the record button, persisted + as `compress_recordings`. + - [x] The temporary WAV is transcoded to `.m4a` after stopping and then deleted; the + M4A is saved to Downloads instead of the WAV. + - [x] Markers still work through the existing JSON sidecar (M4A has no `cue ` chunk). + - [x] `TranscriptionEngine` now streams through `PcmAudioSource`, which decodes + compressed files with `MediaExtractor`/`MediaCodec`, so transcription still + works on M4A. + - [ ] Measure the size win and the transcription-quality impact on a real device and + write the numbers here. Expected size: ~22 MB/hour mono instead of ~317 MB. + +### 3. Forced microphone input (BT-Mic-Force) + +- [~] **Integrate the BT-Mic-Force mic-forcing feature into RECCoon.** + - [x] Added a **Mic** spinner (Auto / Phone mic / detected inputs) to the main + screen. + - [x] The choice is routed into `WavRecorder` via `AudioRecord.setPreferredDevice` + and the `AudioManager` SCO mode (`setMode`, `startBluetoothSco`, + `setBluetoothScoOn`). + - [x] Added `MODIFY_AUDIO_SETTINGS`, `BLUETOOTH_CONNECT` and (pre-31) + `BLUETOOTH`/`BLUETOOTH_ADMIN` permissions; `BLUETOOTH_CONNECT` is requested + only when a Bluetooth input is selected. + - [x] Reused the existing `AudioRecord` capture path; no second foreground service. + - [ ] Verify that Bluetooth SCO actually routes audio on a real device. + +### 4. Live monitoring, output picker, and quick discard + +- [x] **Live monitoring** (`Monitor` checkbox + output picker). `LiveMonitor` plays the + captured PCM through `AudioTrack` on a dedicated thread with a bounded queue, so + a slow output never stalls the recording. Output devices are listed like the mic + picker (Auto / phone speaker / wired / USB / Bluetooth). Feedback warning when + monitoring is enabled. +- [x] **Stop and discard.** A **Discard** button stops the recording and deletes the + temporary file without saving. +- [ ] Verify latency and routing on real devices (wired, Bluetooth A2DP, BT LE). + Bluetooth A2DP adds unavoidable latency; note whether it is usable. +- [ ] Consider requesting audio focus and muting monitoring automatically when the + selected output is the built-in speaker. + +### 5. Rotation feedback (Alberto, from 1.4.0-alpha) + +- [x] **Level meters look upside down when the phone is turned.** Cause: both + activities were locked to `screenOrientation="portrait"`, so nothing followed + the device. + - [x] Removed the portrait lock so the UI rotates with the device. + - [x] Added `configChanges="orientation|screenSize|..."` so a rotation does not + recreate the activity and interrupt an in-progress recording. + - [x] **L/R swapped in landscape.** Android exposes the two mic channels in a fixed + device order; it does not tell the app which physical mic is which, so this is + not reliably auto-detectable. Added a persisted **Swap L/R** checkbox that + exchanges the channels in `WavRecorder`, so both the meters and the saved WAV + match the user's left/right. + - [ ] Confirm on a real device that rotating mid-recording keeps the recording and + the meters upright, and that Swap L/R fixes the inverted meters. + ## Build / distribution - [ ] **The sherpa-onnx binaries are too large to keep in git.** The Android diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1e4768a..1f5fb20 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } android { - namespace = "com.wuhei.reccoon" + namespace = "com.tomcooks.reccoon" compileSdk { version = release(37) { @@ -12,15 +12,16 @@ android { } defaultConfig { - applicationId = "com.wuhei.reccoon" + applicationId = "com.tomcooks.reccoon" minSdk = 26 targetSdk = 35 - versionCode = 6 - versionName = "1.4.0-alpha" + versionCode = 1 + versionName = "1.0.0" ndk { - // Prebuilt sherpa-onnx / onnxruntime native libraries. - abiFilters += listOf("arm64-v8a", "x86_64") + // Prebuilt sherpa-onnx / onnxruntime native libraries. Only arm64 is + // shipped; x86_64 was only useful for emulators and doubled the APK. + abiFilters += listOf("arm64-v8a") } } @@ -58,8 +59,10 @@ dependencies { // Kotlin runtime required by the sherpa-onnx Kotlin API. implementation("org.jetbrains.kotlin:kotlin-stdlib:2.4.20") - // On-device speech recognition (Whisper multilingual: English + Italian). - implementation(files("libs/sherpa-onnx-1.13.8.aar")) + // On-device speech recognition runtime (loads the Parakeet ONNX graphs). + // Pulled from JitPack (an F-Droid-trusted Maven repository) so no prebuilt + // binary has to live in this repo. Byte-identical to the official AAR. + implementation("com.github.k2-fsa.sherpa-onnx:sherpa-onnx:1.13.8@aar") testImplementation("junit:junit:4.13.2") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 791d668..eecbcc1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,11 +2,22 @@ + + + + + + @@ -24,10 +35,16 @@ android:supportsRtl="true" android:theme="@style/Theme.Reccoon"> + + android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode" + android:exported="true"> @@ -36,14 +53,19 @@ + android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode" + android:exported="false" /> + + AAC-LC is used because every Android device ships an encoder for it, + * unlike Opus. At speech bitrates the result is roughly 10-20× smaller than the + * 44.1 kHz 16-bit WAV (for example ~22 MB per hour at 48 kbps mono instead of + * ~317 MB). The lossy step keeps enough bandwidth (16 kHz) for speech + * transcription; see {@link PcmAudioSource} for decoding it again. + */ +public final class AudioCompressor { + + private static final long TIMEOUT_US = 10_000; + private static final int CHUNK_FRAMES = 4096; + + private AudioCompressor() { + } + + /** @return the number of bytes written to {@code m4a}. */ + public static long compressToM4a(File wav, File m4a) throws IOException { + if (m4a.exists() && !m4a.delete()) { + throw new IOException("Cannot replace " + m4a); + } + try (BufferedInputStream in = + new BufferedInputStream(new FileInputStream(wav), 256 * 1024)) { + TranscriptionEngine.WavInfo info = TranscriptionEngine.readWavHeader(in); + if (info.bitsPerSample != 16 || info.audioFormat != 1) { + throw new IOException("Only 16-bit PCM WAV files can be compressed"); + } + encode(in, info, m4a); + } + return m4a.length(); + } + + private static void encode(InputStream pcm, TranscriptionEngine.WavInfo info, File m4a) + throws IOException { + int sampleRate = Math.max(1, info.sampleRate); + int channels = Math.max(1, info.channels); + + MediaFormat format = MediaFormat.createAudioFormat( + MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, channels); + format.setInteger(MediaFormat.KEY_AAC_PROFILE, + MediaCodecInfo.CodecProfileLevel.AACObjectLC); + // Small but still clear for speech: 48 kbps per channel. + format.setInteger(MediaFormat.KEY_BIT_RATE, 48_000 * channels); + format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, CHUNK_FRAMES * channels * 2); + + MediaCodec codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC); + MediaMuxer muxer = new MediaMuxer(m4a.getAbsolutePath(), + MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4); + boolean muxerStarted = false; + boolean inputDone = false; + boolean outputDone = false; + int trackIndex = -1; + long presentationTimeUs = 0L; + long remaining = info.dataSize > 0 ? info.dataSize : Long.MAX_VALUE; + + try { + codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); + codec.start(); + + byte[] buffer = new byte[CHUNK_FRAMES * channels * 2]; + MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); + + while (!outputDone) { + if (!inputDone) { + int inIndex = codec.dequeueInputBuffer(TIMEOUT_US); + if (inIndex >= 0) { + ByteBuffer input = codec.getInputBuffer(inIndex); + int capacity = input == null ? buffer.length : input.capacity(); + int toRead = (int) Math.min(Math.min(buffer.length, remaining), capacity); + int read = toRead <= 0 ? -1 : pcm.read(buffer, 0, toRead); + if (read < 0) { + codec.queueInputBuffer(inIndex, 0, 0, presentationTimeUs, + MediaCodec.BUFFER_FLAG_END_OF_STREAM); + inputDone = true; + } else { + remaining -= read; + input.clear(); + input.put(buffer, 0, read); + codec.queueInputBuffer(inIndex, 0, read, presentationTimeUs, 0); + long frames = read / (channels * 2L); + presentationTimeUs += frames * 1_000_000L / sampleRate; + } + } + } + + int outIndex = codec.dequeueOutputBuffer(bufferInfo, TIMEOUT_US); + if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + trackIndex = muxer.addTrack(codec.getOutputFormat()); + muxer.start(); + muxerStarted = true; + } else if (outIndex >= 0) { + ByteBuffer output = codec.getOutputBuffer(outIndex); + if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) { + bufferInfo.size = 0; + } + if (bufferInfo.size > 0 && muxerStarted && output != null) { + output.position(bufferInfo.offset); + output.limit(bufferInfo.offset + bufferInfo.size); + muxer.writeSampleData(trackIndex, output, bufferInfo); + } + codec.releaseOutputBuffer(outIndex, false); + if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { + outputDone = true; + } + } + } + } finally { + try { + codec.stop(); + } catch (Exception ignored) { + // Ignore. + } + codec.release(); + if (muxerStarted) { + try { + muxer.stop(); + } catch (Exception ignored) { + // Ignore. + } + } + muxer.release(); + } + } +} diff --git a/app/src/main/java/com/tomcooks/reccoon/Formats.java b/app/src/main/java/com/tomcooks/reccoon/Formats.java new file mode 100644 index 0000000..338fd58 --- /dev/null +++ b/app/src/main/java/com/tomcooks/reccoon/Formats.java @@ -0,0 +1,67 @@ +package com.tomcooks.reccoon; + +import java.util.Locale; + +/** Small formatting helpers shared by the activities. */ +public final class Formats { + + private Formats() { + } + + /** Turns {@code reccoon-2026-09-24-153000-00001.wav} into a readable label. */ + public static String prettyName(String fileName) { + if (fileName == null) { + return ""; + } + String core = fileName; + if (core.startsWith("reccoon-")) { + core = core.substring("reccoon-".length()); + } + if (core.endsWith(".wav") || core.endsWith(".m4a")) { + core = core.substring(0, core.length() - 4); + } + String[] parts = core.split("-"); + if (parts.length >= 4) { + String date = parts[0] + "-" + parts[1] + "-" + parts[2]; + String time = parts[3]; + String sequence = parts.length >= 5 ? parts[4] : ""; + if (time.length() == 6) { + time = time.substring(0, 2) + ":" + time.substring(2, 4) + ":" + time.substring(4); + } + return date + " " + time + (sequence.isEmpty() ? "" : " #" + sequence); + } + return core; + } + + public static String duration(long millis) { + long totalSeconds = Math.max(0, millis) / 1000; + long hours = totalSeconds / 3600; + long minutes = (totalSeconds % 3600) / 60; + long seconds = totalSeconds % 60; + if (hours > 0) { + return String.format(Locale.US, "%d:%02d:%02d", hours, minutes, seconds); + } + return String.format(Locale.US, "%d:%02d", minutes, seconds); + } + + public static String size(long bytes) { + double mb = bytes / 1048576.0; + if (mb >= 1) { + return String.format(Locale.US, "%.1f MB", mb); + } + return String.format(Locale.US, "%d KB", bytes / 1024); + } + + public static String humanBytes(long bytes) { + double mb = bytes / 1048576.0; + if (mb >= 1) { + return String.format(Locale.US, "%.0f MB", mb); + } + return String.format(Locale.US, "%.0f KB", bytes / 1024.0); + } + + /** mm:ss or h:mm:ss from milliseconds. */ + public static String clock(int millis) { + return duration(millis); + } +} diff --git a/app/src/main/java/com/tomcooks/reccoon/InsetsHelper.java b/app/src/main/java/com/tomcooks/reccoon/InsetsHelper.java new file mode 100644 index 0000000..27939be --- /dev/null +++ b/app/src/main/java/com/tomcooks/reccoon/InsetsHelper.java @@ -0,0 +1,33 @@ +package com.tomcooks.reccoon; + +import android.view.View; + +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; + +/** + * Adds the system bar insets to a root view's padding so content is not hidden + * behind the status bar / notch or the navigation bar (edge-to-edge safe). + */ +public final class InsetsHelper { + + private InsetsHelper() { + } + + public static void apply(View root) { + if (root == null) { + return; + } + final int left = root.getPaddingLeft(); + final int top = root.getPaddingTop(); + final int right = root.getPaddingRight(); + final int bottom = root.getPaddingBottom(); + ViewCompat.setOnApplyWindowInsetsListener(root, (view, windowInsets) -> { + Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); + view.setPadding(left + insets.left, top + insets.top, + right + insets.right, bottom + insets.bottom); + return windowInsets; + }); + } +} diff --git a/app/src/main/java/com/tomcooks/reccoon/LevelMeterView.java b/app/src/main/java/com/tomcooks/reccoon/LevelMeterView.java new file mode 100644 index 0000000..362ee6a --- /dev/null +++ b/app/src/main/java/com/tomcooks/reccoon/LevelMeterView.java @@ -0,0 +1,160 @@ +package com.tomcooks.reccoon; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.Shader; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.Nullable; + +/** + * Two-channel (left/right) peak level meter with a decaying peak hold and a + * clip indicator. Levels are supplied as 0..1 linear amplitudes and mapped to + * a dBFS scale. + */ +public class LevelMeterView extends View { + + private static final float MIN_DB = -60f; + private static final long CLIP_HOLD_MS = 900L; + + private final Paint backgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint barPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint peakPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint clipPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + + private final RectF barRect = new RectF(); + + private Shader gradient; + + private float leftTarget; + private float rightTarget; + private float leftLevel; + private float rightLevel; + private float leftPeak; + private float rightPeak; + private long clipUntil; + + public LevelMeterView(Context context) { + this(context, null); + } + + public LevelMeterView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + float density = getResources().getDisplayMetrics().density; + backgroundPaint.setColor(0x22FFFFFF); + peakPaint.setColor(0xFFFFFFFF); + labelPaint.setColor(0xFF93A0B0); + labelPaint.setTextSize(11f * density); + clipPaint.setColor(0xFFE53935); + clipPaint.setTextSize(11f * density); + clipPaint.setFakeBoldText(true); + clipPaint.setTextAlign(Paint.Align.RIGHT); + } + + /** Feed one measurement window. Amplitudes are 0..1. */ + public void setLevels(float leftPeak, float rightPeak, boolean clipped) { + this.leftTarget = clamp(leftPeak); + this.rightTarget = clamp(rightPeak); + if (clipped) { + clipUntil = System.currentTimeMillis() + CLIP_HOLD_MS; + } + invalidate(); + } + + public void reset() { + leftTarget = rightTarget = leftLevel = rightLevel = 0f; + leftPeak = rightPeak = 0f; + clipUntil = 0L; + invalidate(); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + gradient = new LinearGradient(0, 0, w, 0, + new int[]{0xFF3DDC84, 0xFF9CCC65, 0xFFFFD54F, 0xFFFF5252}, + new float[]{0f, 0.55f, 0.8f, 1f}, + Shader.TileMode.CLAMP); + } + + @Override + protected void onDraw(Canvas canvas) { + leftLevel = approach(leftLevel, leftTarget); + rightLevel = approach(rightLevel, rightTarget); + leftPeak = Math.max(leftTarget, leftPeak * 0.92f); + rightPeak = Math.max(rightTarget, rightPeak * 0.92f); + + float density = getResources().getDisplayMetrics().density; + float gap = 6f * density; + float barHeight = (getHeight() - gap) / 2f; + float labelWidth = labelPaint.measureText("L") + 10f * density; + float rightEdge = getWidth() - clipPaint.measureText("CLIP") - 10f * density; + + drawBar(canvas, 0f, labelWidth, rightEdge - labelWidth, barHeight, + leftLevel, leftPeak, "L"); + drawBar(canvas, barHeight + gap, labelWidth, rightEdge - labelWidth, barHeight, + rightLevel, rightPeak, "R"); + + if (System.currentTimeMillis() < clipUntil) { + float baseline = getHeight() / 2f - (clipPaint.descent() + clipPaint.ascent()) / 2f; + canvas.drawText("CLIP", getWidth(), baseline, clipPaint); + } + + // Keep animating while there is something to decay. + if (leftLevel > 0.002f || rightLevel > 0.002f + || leftPeak > 0.002f || rightPeak > 0.002f) { + postInvalidateOnAnimation(); + } + } + + private void drawBar(Canvas canvas, float top, float left, float width, float height, + float level, float peak, String label) { + float density = getResources().getDisplayMetrics().density; + float labelBaseline = top + height / 2f - (labelPaint.descent() + labelPaint.ascent()) / 2f; + canvas.drawText(label, 0f, labelBaseline, labelPaint); + + barRect.set(left, top, left + width, top + height); + canvas.drawRoundRect(barRect, height / 2f, height / 2f, backgroundPaint); + + float filled = width * toPosition(level); + if (filled > 0f) { + barPaint.setShader(gradient); + barRect.set(left, top, left + filled, top + height); + canvas.drawRoundRect(barRect, height / 2f, height / 2f, barPaint); + barPaint.setShader(null); + } + + float peakX = left + width * toPosition(peak); + if (peakX > left + 1f) { + canvas.drawRect(peakX - 1.5f * density, top, peakX, top + height, peakPaint); + } + } + + /** Maps a linear amplitude to a 0..1 bar position on a dBFS scale. */ + private static float toPosition(float amplitude) { + if (amplitude <= 0f) { + return 0f; + } + double db = 20.0 * Math.log10(amplitude); + if (db < MIN_DB) { + return 0f; + } + return clamp((float) ((db - MIN_DB) / -MIN_DB)); + } + + private static float approach(float current, float target) { + if (target >= current) { + return target; + } + return current + (target - current) * 0.25f; + } + + private static float clamp(float value) { + return Math.max(0f, Math.min(1f, value)); + } +} diff --git a/app/src/main/java/com/tomcooks/reccoon/LiveMonitor.java b/app/src/main/java/com/tomcooks/reccoon/LiveMonitor.java new file mode 100644 index 0000000..6366d84 --- /dev/null +++ b/app/src/main/java/com/tomcooks/reccoon/LiveMonitor.java @@ -0,0 +1,189 @@ +package com.tomcooks.reccoon; + +import android.content.Context; +import android.media.AudioAttributes; +import android.media.AudioDeviceInfo; +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioTrack; + +import java.util.ArrayDeque; + +/** + * Plays captured PCM back to a chosen output device so the user can hear what is + * being recorded (live monitoring / sidetone). + * + *

The monitor is decoupled from the capture loop by a small bounded queue and + * a dedicated thread: if the output device is slow (for example Bluetooth A2DP + * with its inherent latency) chunks are dropped instead of stalling the + * recorder and corrupting the recording. + * + *

With the phone speaker as the output and the phone microphone as the input + * there will be feedback, so headphones are recommended. + */ +public final class LiveMonitor { + + private static final int MAX_QUEUED_CHUNKS = 8; + + private final AudioManager audioManager; + private final ArrayDeque queue = new ArrayDeque<>(); + + private AudioTrack track; + private Thread thread; + private volatile boolean running; + private int preferredDeviceId = -1; + + public LiveMonitor(Context context) { + audioManager = (AudioManager) context.getApplicationContext() + .getSystemService(Context.AUDIO_SERVICE); + } + + public void setPreferredDeviceId(int deviceId) { + this.preferredDeviceId = deviceId; + } + + public boolean isRunning() { + return running; + } + + /** @return true when the output could be opened and playback started. */ + public boolean start(int sampleRate, int channels) { + stop(); + int channelMask = channels > 1 + ? AudioFormat.CHANNEL_OUT_STEREO : AudioFormat.CHANNEL_OUT_MONO; + int minBuffer = AudioTrack.getMinBufferSize( + sampleRate, channelMask, AudioFormat.ENCODING_PCM_16BIT); + int target = sampleRate * channels * 2 / 5; // ~200 ms + int bufferSize = Math.max(Math.max(minBuffer, target), 1024); + + try { + track = new AudioTrack.Builder() + .setAudioAttributes(new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build()) + .setAudioFormat(new AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setSampleRate(sampleRate) + .setChannelMask(channelMask) + .build()) + .setBufferSizeInBytes(bufferSize) + .setTransferMode(AudioTrack.MODE_STREAM) + .setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY) + .build(); + } catch (Exception e) { + track = null; + return false; + } + + if (track.getState() != AudioTrack.STATE_INITIALIZED) { + track.release(); + track = null; + return false; + } + applyPreferredDevice(); + try { + track.play(); + } catch (Exception e) { + track.release(); + track = null; + return false; + } + + running = true; + thread = new Thread(this::loop, "reccoon-monitor"); + thread.start(); + return true; + } + + /** Queues a chunk of PCM to play. Safe to call from the capture thread. */ + public void write(byte[] data, int length) { + if (!running || data == null || length <= 0) { + return; + } + byte[] copy = new byte[length]; + System.arraycopy(data, 0, copy, 0, length); + synchronized (queue) { + while (queue.size() >= MAX_QUEUED_CHUNKS) { + queue.pollFirst(); + } + queue.addLast(copy); + queue.notifyAll(); + } + } + + private void loop() { + while (running) { + byte[] chunk; + synchronized (queue) { + while (running && queue.isEmpty()) { + try { + queue.wait(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + chunk = queue.pollFirst(); + } + if (chunk == null) { + continue; + } + AudioTrack target = track; + if (target != null) { + try { + target.write(chunk, 0, chunk.length); + } catch (Exception ignored) { + // Drop this chunk and keep going. + } + } + } + } + + public void stop() { + running = false; + synchronized (queue) { + queue.clear(); + queue.notifyAll(); + } + Thread current = thread; + thread = null; + if (current != null) { + try { + current.join(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + AudioTrack target = track; + track = null; + if (target != null) { + try { + target.stop(); + } catch (Exception ignored) { + // Ignore. + } + try { + target.release(); + } catch (Exception ignored) { + // Ignore. + } + } + } + + private void applyPreferredDevice() { + if (preferredDeviceId < 0 || audioManager == null || track == null) { + return; + } + try { + for (AudioDeviceInfo device : audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)) { + if (device.getId() == preferredDeviceId) { + track.setPreferredDevice(device); + return; + } + } + } catch (Exception ignored) { + // Keep the default output. + } + } +} diff --git a/app/src/main/java/com/tomcooks/reccoon/MainActivity.java b/app/src/main/java/com/tomcooks/reccoon/MainActivity.java new file mode 100644 index 0000000..b350965 --- /dev/null +++ b/app/src/main/java/com/tomcooks/reccoon/MainActivity.java @@ -0,0 +1,933 @@ +package com.tomcooks.reccoon; + +import android.Manifest; +import android.animation.ObjectAnimator; +import android.app.NotificationManager; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.media.AudioDeviceInfo; +import android.media.AudioManager; +import android.media.MediaScannerConnection; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import android.provider.MediaStore; +import android.provider.Settings; +import android.text.InputType; +import android.view.LayoutInflater; +import android.view.View; +import android.view.WindowManager; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.AppCompatCheckBox; +import androidx.core.content.ContextCompat; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +public class MainActivity extends AppCompatActivity { + + private static final int REQUEST_PERMISSIONS = 42; + private static final String PREFS = "reccoon"; + private static final String PREF_COUNTER = "counter"; + private static final String PREF_SILENCE = "silence_notifications"; + private static final String PREF_MONO = "mono_recording"; + private static final String PREF_COMPRESS = "compress_recordings"; + private static final String PREF_INPUT = "input_device"; + private static final String PREF_SWAP = "swap_channels"; + private static final String PREF_MONITOR = "live_monitor"; + private static final String PREF_OUTPUT = "output_device"; + + /** Spinner positions that are not a concrete {@link AudioDeviceInfo}. */ + private static final int INPUT_AUTO = -1; + private static final int INPUT_PHONE = -2; + private static final int OUTPUT_AUTO = -1; + private static final int OUTPUT_SPEAKER = -2; + + private ImageButton recordButton; + private ImageView recIndicator; + private TextView timerText; + private TextView statusText; + private LevelMeterView levelMeter; + private WaveformView waveformView; + private AppCompatCheckBox silenceCheckbox; + private AppCompatCheckBox monoCheckbox; + private AppCompatCheckBox compressCheckbox; + private AppCompatCheckBox swapCheckbox; + private AppCompatCheckBox monitorCheckbox; + private Spinner inputSpinner; + private Spinner outputSpinner; + private final List inputDevices = new ArrayList<>(); + private final List outputDevices = new ArrayList<>(); + private Button markButton; + private Button discardButton; + private LinearLayout recordingsContainer; + private TextView emptyText; + private int savedInterruptionFilter = -1; + + private WavRecorder recorder; + private RecordingStore recordingStore; + private TranscriptStore transcriptStore; + private ObjectAnimator blinkAnimator; + private final Handler handler = new Handler(Looper.getMainLooper()); + + private boolean recording; + private boolean busy; + private long startedAtMs; + private Runnable timerRunnable; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + recordButton = findViewById(R.id.recordButton); + recIndicator = findViewById(R.id.recIndicator); + timerText = findViewById(R.id.timerText); + statusText = findViewById(R.id.statusText); + levelMeter = findViewById(R.id.levelMeter); + waveformView = findViewById(R.id.waveformView); + silenceCheckbox = findViewById(R.id.silenceCheckbox); + monoCheckbox = findViewById(R.id.monoCheckbox); + compressCheckbox = findViewById(R.id.compressCheckbox); + swapCheckbox = findViewById(R.id.swapCheckbox); + monitorCheckbox = findViewById(R.id.monitorCheckbox); + inputSpinner = findViewById(R.id.inputSpinner); + outputSpinner = findViewById(R.id.outputSpinner); + markButton = findViewById(R.id.markButton); + discardButton = findViewById(R.id.discardButton); + recordingsContainer = findViewById(R.id.recordingsContainer); + emptyText = findViewById(R.id.emptyText); + + recordingStore = new RecordingStore(this); + transcriptStore = new TranscriptStore(this); + + recorder = new WavRecorder(this); + recorder.setLevelListener((leftPeak, rightPeak, clipped, waveform) -> + runOnUiThread(() -> { + if (recording) { + levelMeter.setLevels(leftPeak, rightPeak, clipped); + waveformView.append(waveform); + } + })); + + setUpSilence(); + setUpMono(); + setUpCompress(); + setUpSwap(); + setUpMonitor(); + populateInputDevices(); + populateOutputDevices(); + markButton.setOnClickListener(v -> addMarker()); + discardButton.setOnClickListener(v -> { + if (recording && !busy) { + stopRecording(true); + } + }); + InsetsHelper.apply(findViewById(R.id.root)); + + blinkAnimator = ObjectAnimator.ofFloat(recIndicator, View.ALPHA, 1f, 0.15f); + blinkAnimator.setDuration(600); + blinkAnimator.setRepeatCount(ObjectAnimator.INFINITE); + blinkAnimator.setRepeatMode(ObjectAnimator.REVERSE); + + recordButton.setOnClickListener(v -> onRecordButtonClicked()); + } + + @Override + protected void onResume() { + super.onResume(); + if (inputDevices.isEmpty()) { + populateInputDevices(); + } + if (outputDevices.isEmpty()) { + populateOutputDevices(); + } + renderRecordings(); + } + + private void renderRecordings() { + recordingsContainer.removeAllViews(); + List recordings = recordingStore.list(); + emptyText.setVisibility(recordings.isEmpty() ? View.VISIBLE : View.GONE); + + LayoutInflater inflater = LayoutInflater.from(this); + for (RecordingStore.Recording recording : recordings) { + View row = inflater.inflate(R.layout.item_recording, recordingsContainer, false); + TextView name = row.findViewById(R.id.nameText); + TextView meta = row.findViewById(R.id.metaText); + TextView badge = row.findViewById(R.id.transcriptBadge); + + name.setText(Formats.prettyName(recording.name)); + meta.setText(Formats.duration(recording.durationMs) + + " · " + Formats.size(recording.sizeBytes)); + badge.setVisibility(transcriptStore.has(recording.name) ? View.VISIBLE : View.GONE); + row.setOnClickListener(v -> openRecording(recording)); + recordingsContainer.addView(row); + } + } + + private void openRecording(RecordingStore.Recording recording) { + Intent intent = new Intent(this, PlayerActivity.class); + intent.putExtra(PlayerActivity.EXTRA_NAME, recording.name); + intent.putExtra(PlayerActivity.EXTRA_URI, recording.uri.toString()); + startActivity(intent); + } + + @Override + protected void onDestroy() { + handler.removeCallbacksAndMessages(null); + if (blinkAnimator != null) { + blinkAnimator.cancel(); + } + applySilence(false); + super.onDestroy(); + } + + private void onRecordButtonClicked() { + if (busy) { + return; + } + if (recording) { + stopRecording(false); + return; + } + List missing = missingPermissions(); + if (!missing.isEmpty()) { + requestPermissions(missing.toArray(new String[0]), REQUEST_PERMISSIONS); + } else { + startRecording(); + } + } + + private List missingPermissions() { + List missing = new ArrayList<>(); + if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) + != PackageManager.PERMISSION_GRANTED) { + missing.add(Manifest.permission.RECORD_AUDIO); + } + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P + && ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED) { + missing.add(Manifest.permission.WRITE_EXTERNAL_STORAGE); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + && (selectedInputIsBluetooth() || selectedOutputIsBluetooth()) + && ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) + != PackageManager.PERMISSION_GRANTED) { + missing.add(Manifest.permission.BLUETOOTH_CONNECT); + } + return missing; + } + + @Override + public void onRequestPermissionsResult(int requestCode, + @NonNull String[] permissions, + @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode != REQUEST_PERMISSIONS) { + return; + } + if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) + == PackageManager.PERMISSION_GRANTED) { + populateInputDevices(); + startRecording(); + } else { + Toast.makeText(this, R.string.err_permission, Toast.LENGTH_LONG).show(); + } + } + + private void setUpSilence() { + SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); + silenceCheckbox.setChecked(prefs.getBoolean(PREF_SILENCE, false)); + silenceCheckbox.setOnCheckedChangeListener((button, checked) -> { + getSharedPreferences(PREFS, MODE_PRIVATE) + .edit().putBoolean(PREF_SILENCE, checked).apply(); + if (checked) { + ensureDndAccess(); + } + }); + } + + private void setUpMono() { + SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); + boolean mono = prefs.getBoolean(PREF_MONO, false); + monoCheckbox.setChecked(mono); + recorder.setForceMono(mono); + monoCheckbox.setOnCheckedChangeListener((button, checked) -> { + getSharedPreferences(PREFS, MODE_PRIVATE) + .edit().putBoolean(PREF_MONO, checked).apply(); + recorder.setForceMono(checked); + }); + } + + private void setUpCompress() { + SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); + compressCheckbox.setChecked(prefs.getBoolean(PREF_COMPRESS, false)); + compressCheckbox.setOnCheckedChangeListener((button, checked) -> + getSharedPreferences(PREFS, MODE_PRIVATE) + .edit().putBoolean(PREF_COMPRESS, checked).apply()); + } + + private void setUpSwap() { + SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); + swapCheckbox.setChecked(prefs.getBoolean(PREF_SWAP, false)); + swapCheckbox.setOnCheckedChangeListener((button, checked) -> + getSharedPreferences(PREFS, MODE_PRIVATE) + .edit().putBoolean(PREF_SWAP, checked).apply()); + } + + private void setUpMonitor() { + SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); + monitorCheckbox.setChecked(prefs.getBoolean(PREF_MONITOR, false)); + monitorCheckbox.setOnCheckedChangeListener((button, checked) -> { + getSharedPreferences(PREFS, MODE_PRIVATE) + .edit().putBoolean(PREF_MONITOR, checked).apply(); + if (checked) { + Toast.makeText(this, R.string.monitor_warning, Toast.LENGTH_LONG).show(); + } + }); + } + + // --- Output source for live monitoring --------------------------------- + + private void populateOutputDevices() { + int selected = getSharedPreferences(PREFS, MODE_PRIVATE).getInt(PREF_OUTPUT, OUTPUT_AUTO); + outputDevices.clear(); + List labels = new ArrayList<>(); + labels.add(getString(R.string.output_auto)); + labels.add(getString(R.string.output_speaker)); + + AudioManager manager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + if (manager != null) { + try { + for (AudioDeviceInfo device : manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)) { + // The built-in speaker is already covered by the entry above. + if (device.getType() == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) { + continue; + } + outputDevices.add(device); + labels.add(describeOutput(device)); + } + } catch (Exception ignored) { + // No device list available. + } + } + + ArrayAdapter adapter = new ArrayAdapter<>(this, + android.R.layout.simple_spinner_item, labels); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + outputSpinner.setAdapter(adapter); + + if (selected == OUTPUT_SPEAKER) { + outputSpinner.setSelection(1); + return; + } + for (int i = 0; i < outputDevices.size(); i++) { + if (outputDevices.get(i).getId() == selected) { + outputSpinner.setSelection(i + 2); + return; + } + } + outputSpinner.setSelection(0); + } + + /** Resolves the spinner to a concrete device id, or -1 for the default. */ + private int resolveOutputDeviceId() { + int position = outputSpinner.getSelectedItemPosition(); + if (position == 1) { + return findBuiltinSpeakerId(); + } + if (position >= 2 && position - 2 < outputDevices.size()) { + return outputDevices.get(position - 2).getId(); + } + return OUTPUT_AUTO; + } + + private int selectedOutputId() { + int position = outputSpinner.getSelectedItemPosition(); + if (position == 1) { + return OUTPUT_SPEAKER; + } + if (position >= 2 && position - 2 < outputDevices.size()) { + return outputDevices.get(position - 2).getId(); + } + return OUTPUT_AUTO; + } + + private boolean selectedOutputIsBluetooth() { + int position = outputSpinner.getSelectedItemPosition(); + return position >= 2 && position - 2 < outputDevices.size() + && isBluetoothOutput(outputDevices.get(position - 2)); + } + + private static boolean isBluetoothOutput(AudioDeviceInfo device) { + int type = device.getType(); + return type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP + || type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO + || type == AudioDeviceInfo.TYPE_BLE_HEADSET + || type == AudioDeviceInfo.TYPE_BLE_SPEAKER; + } + + private int findBuiltinSpeakerId() { + AudioManager manager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + if (manager == null) { + return OUTPUT_AUTO; + } + try { + for (AudioDeviceInfo device : manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)) { + if (device.getType() == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) { + return device.getId(); + } + } + } catch (Exception ignored) { + // Fall through to the default output. + } + return OUTPUT_AUTO; + } + + private static String describeOutput(AudioDeviceInfo device) { + String type; + switch (device.getType()) { + case AudioDeviceInfo.TYPE_BLUETOOTH_A2DP: + type = "Bluetooth"; + break; + case AudioDeviceInfo.TYPE_BLUETOOTH_SCO: + type = "Bluetooth (SCO)"; + break; + case AudioDeviceInfo.TYPE_BLE_HEADSET: + type = "Bluetooth LE headset"; + break; + case AudioDeviceInfo.TYPE_BLE_SPEAKER: + type = "Bluetooth LE speaker"; + break; + case AudioDeviceInfo.TYPE_WIRED_HEADPHONES: + type = "Wired headphones"; + break; + case AudioDeviceInfo.TYPE_WIRED_HEADSET: + type = "Wired headset"; + break; + case AudioDeviceInfo.TYPE_USB_DEVICE: + type = "USB device"; + break; + case AudioDeviceInfo.TYPE_USB_HEADSET: + type = "USB headset"; + break; + case AudioDeviceInfo.TYPE_BUILTIN_EARPIECE: + type = "Earpiece"; + break; + default: + type = "Output " + device.getType(); + break; + } + CharSequence name = device.getProductName(); + if (name != null && name.length() > 0) { + type += " \u2014 " + name; + } + return type; + } + + // --- Microphone source (BT-Mic-Force integration) --------------------- + + /** A resolved microphone choice to hand to {@link WavRecorder}. */ + private static final class InputChoice { + final int deviceId; + final boolean bluetoothSco; + + InputChoice(int deviceId, boolean bluetoothSco) { + this.deviceId = deviceId; + this.bluetoothSco = bluetoothSco; + } + } + + private void populateInputDevices() { + int selected = getSharedPreferences(PREFS, MODE_PRIVATE).getInt(PREF_INPUT, INPUT_AUTO); + inputDevices.clear(); + List labels = new ArrayList<>(); + labels.add(getString(R.string.input_auto)); + labels.add(getString(R.string.input_phone)); + + AudioManager manager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + if (manager != null + && ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) + == PackageManager.PERMISSION_GRANTED) { + try { + for (AudioDeviceInfo device : manager.getDevices(AudioManager.GET_DEVICES_INPUTS)) { + // The built-in mic is already covered by the "Phone mic" entry. + if (device.getType() == AudioDeviceInfo.TYPE_BUILTIN_MIC) { + continue; + } + inputDevices.add(device); + labels.add(describeInput(device)); + } + } catch (Exception ignored) { + // No device list available. + } + } + + ArrayAdapter adapter = new ArrayAdapter<>(this, + android.R.layout.simple_spinner_item, labels); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + inputSpinner.setAdapter(adapter); + + if (selected == INPUT_PHONE) { + inputSpinner.setSelection(1); + return; + } + for (int i = 0; i < inputDevices.size(); i++) { + if (inputDevices.get(i).getId() == selected) { + inputSpinner.setSelection(i + 2); + return; + } + } + inputSpinner.setSelection(0); + } + + private InputChoice resolveInput() { + int position = inputSpinner.getSelectedItemPosition(); + if (position == 1) { + return new InputChoice(findBuiltinMicId(), false); + } + if (position >= 2 && position - 2 < inputDevices.size()) { + AudioDeviceInfo device = inputDevices.get(position - 2); + return new InputChoice(device.getId(), isBluetoothInput(device)); + } + return new InputChoice(INPUT_AUTO, false); + } + + private int selectedInputId() { + int position = inputSpinner.getSelectedItemPosition(); + if (position == 1) { + return INPUT_PHONE; + } + if (position >= 2 && position - 2 < inputDevices.size()) { + return inputDevices.get(position - 2).getId(); + } + return INPUT_AUTO; + } + + private boolean selectedInputIsBluetooth() { + int position = inputSpinner.getSelectedItemPosition(); + return position >= 2 && position - 2 < inputDevices.size() + && isBluetoothInput(inputDevices.get(position - 2)); + } + + private int findBuiltinMicId() { + AudioManager manager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + if (manager == null) { + return INPUT_AUTO; + } + try { + for (AudioDeviceInfo device : manager.getDevices(AudioManager.GET_DEVICES_INPUTS)) { + if (device.getType() == AudioDeviceInfo.TYPE_BUILTIN_MIC) { + return device.getId(); + } + } + } catch (Exception ignored) { + // Fall through to auto. + } + return INPUT_AUTO; + } + + private static boolean isBluetoothInput(AudioDeviceInfo device) { + int type = device.getType(); + return type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO + || type == AudioDeviceInfo.TYPE_BLE_HEADSET + || type == AudioDeviceInfo.TYPE_BLE_SPEAKER; + } + + private static String describeInput(AudioDeviceInfo device) { + String type; + switch (device.getType()) { + case AudioDeviceInfo.TYPE_BLUETOOTH_SCO: + type = "Bluetooth (SCO)"; + break; + case AudioDeviceInfo.TYPE_BLE_HEADSET: + type = "Bluetooth LE headset"; + break; + case AudioDeviceInfo.TYPE_BLE_SPEAKER: + type = "Bluetooth LE speaker"; + break; + case AudioDeviceInfo.TYPE_HEARING_AID: + type = "Hearing aid"; + break; + case AudioDeviceInfo.TYPE_USB_DEVICE: + type = "USB device"; + break; + case AudioDeviceInfo.TYPE_USB_HEADSET: + type = "USB headset"; + break; + case AudioDeviceInfo.TYPE_WIRED_HEADSET: + type = "Wired headset"; + break; + default: + type = "Input " + device.getType(); + break; + } + CharSequence name = device.getProductName(); + if (name != null && name.length() > 0) { + type += " \u2014 " + name; + } + return type; + } + + private void addMarker() { + if (!recording) { + return; + } + final long offset = recorder.currentSampleOffset(); + final int next = recorder.getMarkers().size() + 1; + + EditText input = new EditText(this); + input.setInputType(InputType.TYPE_CLASS_TEXT); + input.setHint(R.string.marker_hint); + input.setText(getString(R.string.marker_fmt, next)); + input.selectAll(); + + int padding = (int) (20 * getResources().getDisplayMetrics().density); + FrameLayout container = new FrameLayout(this); + container.setPadding(padding, padding / 2, padding, 0); + container.addView(input); + + new AlertDialog.Builder(this) + .setTitle(R.string.marker_title) + .setView(container) + .setPositiveButton(R.string.marker_add, (dialog, which) -> { + String label = input.getText().toString().trim(); + if (label.isEmpty()) { + label = getString(R.string.marker_fmt, next); + } + recorder.addMarkerAt(offset, label); + Toast.makeText(this, getString(R.string.marker_added_fmt, next), + Toast.LENGTH_SHORT).show(); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + } + + private void ensureDndAccess() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return; + } + NotificationManager manager = + (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + if (manager == null || manager.isNotificationPolicyAccessGranted()) { + return; + } + new AlertDialog.Builder(this) + .setTitle(R.string.dnd_title) + .setMessage(R.string.dnd_message) + .setPositiveButton(R.string.dnd_open_settings, (dialog, which) -> { + try { + startActivity(new Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS)); + } catch (Exception ignored) { + // Ignore. + } + }) + .setNegativeButton(R.string.cancel, + (dialog, which) -> silenceCheckbox.setChecked(false)) + .show(); + } + + /** Enables total-silence Do Not Disturb while recording, then restores it. */ + private void applySilence(boolean enable) { + if (silenceCheckbox == null || !silenceCheckbox.isChecked()) { + return; + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return; + } + NotificationManager manager = + (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + if (manager == null || !manager.isNotificationPolicyAccessGranted()) { + return; + } + try { + if (enable) { + if (savedInterruptionFilter == -1) { + savedInterruptionFilter = manager.getCurrentInterruptionFilter(); + } + manager.setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_NONE); + } else if (savedInterruptionFilter != -1) { + manager.setInterruptionFilter(savedInterruptionFilter); + savedInterruptionFilter = -1; + } + } catch (Exception ignored) { + // Ignore. + } + } + + private void startRecordingService() { + try { + ContextCompat.startForegroundService(this, + new Intent(this, RecordingService.class) + .setAction(RecordingService.ACTION_START)); + } catch (Exception ignored) { + // Recording still works while the app is in the foreground. + } + } + + private void stopRecordingService() { + try { + stopService(new Intent(this, RecordingService.class)); + } catch (Exception ignored) { + // Ignore. + } + } + + private void startRecording() { + InputChoice input = resolveInput(); + getSharedPreferences(PREFS, MODE_PRIVATE) + .edit().putInt(PREF_INPUT, selectedInputId()).apply(); + recorder.setPreferredDeviceId(input.deviceId); + recorder.setBluetoothSco(input.bluetoothSco); + recorder.setSwapChannels(swapCheckbox.isChecked()); + recorder.setForceMono(monoCheckbox.isChecked()); + recorder.setMonitoring(monitorCheckbox.isChecked()); + recorder.setMonitorDeviceId(resolveOutputDeviceId()); + getSharedPreferences(PREFS, MODE_PRIVATE) + .edit().putInt(PREF_OUTPUT, selectedOutputId()).apply(); + try { + recorder.start(); + } catch (Exception e) { + Toast.makeText(this, R.string.err_record, Toast.LENGTH_LONG).show(); + return; + } + if (monitorCheckbox.isChecked() && !recorder.isMonitoring()) { + Toast.makeText(this, R.string.err_monitor, Toast.LENGTH_LONG).show(); + } + + recording = true; + startedAtMs = SystemClock.elapsedRealtime(); + + startRecordingService(); + levelMeter.reset(); + waveformView.reset(); + markButton.setEnabled(true); + discardButton.setEnabled(true); + applySilence(true); + recordButton.setBackgroundResource(R.drawable.bg_record_active); + recordButton.setContentDescription(getString(R.string.cd_stop)); + recIndicator.setVisibility(View.VISIBLE); + blinkAnimator.start(); + statusText.setText(R.string.status_recording); + timerText.setText(R.string.timer_zero); + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + + timerRunnable = new Runnable() { + @Override + public void run() { + long elapsed = SystemClock.elapsedRealtime() - startedAtMs; + timerText.setText(formatDuration(elapsed)); + handler.postDelayed(this, 200); + } + }; + handler.post(timerRunnable); + } + + private void stopRecording(boolean discard) { + recording = false; + busy = true; + + stopRecordingService(); + levelMeter.reset(); + waveformView.reset(); + markButton.setEnabled(false); + discardButton.setEnabled(false); + applySilence(false); + long elapsed = SystemClock.elapsedRealtime() - startedAtMs; + handler.removeCallbacks(timerRunnable); + blinkAnimator.cancel(); + recIndicator.setVisibility(View.INVISIBLE); + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + timerText.setText(formatDuration(elapsed)); + recordButton.setBackgroundResource(R.drawable.bg_record_idle); + recordButton.setContentDescription(getString(R.string.cd_record)); + recordButton.setEnabled(false); + statusText.setText(discard ? R.string.status_discarding : R.string.status_saving); + final boolean compress = compressCheckbox.isChecked(); + + new Thread(() -> { + File wav; + List markers; + try { + wav = recorder.stop(); + markers = recorder.getMarkers(); + } catch (Exception e) { + wav = null; + markers = Collections.emptyList(); + } + + String savedName = null; + boolean discarded = false; + if (wav != null && discard) { + //noinspection ResultOfMethodCallIgnored + wav.delete(); + discarded = true; + } else if (wav != null) { + try { + if (compress) { + File m4a = new File(getCacheDir(), "reccoon_recording.m4a"); + try { + AudioCompressor.compressToM4a(wav, m4a); + savedName = saveToDownloads( + m4a, buildFileName(".m4a"), "audio/mp4"); + } catch (Exception compressionFailed) { + savedName = null; + } finally { + //noinspection ResultOfMethodCallIgnored + m4a.delete(); + } + if (savedName == null) { + // Never lose a recording just because compression failed. + savedName = saveToDownloads( + wav, buildFileName(".wav"), "audio/wav"); + } + //noinspection ResultOfMethodCallIgnored + wav.delete(); + } else { + savedName = saveToDownloads( + wav, buildFileName(".wav"), "audio/wav"); + } + if (savedName != null && !markers.isEmpty()) { + new MarkerStore(this).save(savedName, markers); + } + } catch (Exception ignored) { + savedName = null; + } + } + + final String result = savedName; + final boolean wasDiscarded = discarded; + runOnUiThread(() -> { + busy = false; + recordButton.setEnabled(true); + if (wasDiscarded) { + statusText.setText(R.string.discarded); + Toast.makeText(this, R.string.discarded, Toast.LENGTH_SHORT).show(); + } else if (result != null) { + statusText.setText(getString(R.string.saved_fmt, result)); + Toast.makeText(this, getString(R.string.saved_fmt, result), Toast.LENGTH_LONG).show(); + } else { + statusText.setText(R.string.err_save); + Toast.makeText(this, R.string.err_save, Toast.LENGTH_LONG).show(); + } + renderRecordings(); + }); + }, "reccoon-save").start(); + } + + private String saveToDownloads(File file, String fileName, String mimeType) + throws IOException { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + saveViaMediaStore(file, fileName, mimeType); + } else { + saveToPublicDownloads(file, fileName, mimeType); + } + return fileName; + } + + private void saveViaMediaStore(File source, String fileName, String mimeType) + throws IOException { + ContentResolver resolver = getContentResolver(); + ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName); + values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType); + values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS); + values.put(MediaStore.MediaColumns.IS_PENDING, 1); + + Uri uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values); + if (uri == null) { + throw new IOException("MediaStore rejected the new file"); + } + + try (OutputStream out = resolver.openOutputStream(uri)) { + if (out == null) { + throw new IOException("MediaStore returned no output stream"); + } + try (InputStream in = new FileInputStream(source)) { + copy(in, out); + } + } + + ContentValues done = new ContentValues(); + done.put(MediaStore.MediaColumns.IS_PENDING, 0); + resolver.update(uri, done, null, null); + } + + @SuppressWarnings("deprecation") + private void saveToPublicDownloads(File source, String fileName, String mimeType) + throws IOException { + File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("Could not create the Downloads directory"); + } + File outFile = new File(dir, fileName); + try (InputStream in = new FileInputStream(source); + OutputStream out = new FileOutputStream(outFile)) { + copy(in, out); + } + MediaScannerConnection.scanFile( + this, + new String[]{outFile.getAbsolutePath()}, + new String[]{mimeType}, + null); + } + + private String buildFileName(String extension) { + String stamp = new SimpleDateFormat("yyyy-MM-dd-HHmmss", Locale.US).format(new Date()); + SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); + int next = prefs.getInt(PREF_COUNTER, 0) + 1; + prefs.edit().putInt(PREF_COUNTER, next).apply(); + return String.format(Locale.US, "reccoon-%s-%05d%s", stamp, next, extension); + } + + private static void copy(InputStream in, OutputStream out) throws IOException { + byte[] buffer = new byte[64 * 1024]; + int read; + while ((read = in.read(buffer)) > 0) { + out.write(buffer, 0, read); + } + } + + private static String formatDuration(long elapsedMs) { + long totalSeconds = elapsedMs / 1000; + long hours = totalSeconds / 3600; + long minutes = (totalSeconds % 3600) / 60; + long seconds = totalSeconds % 60; + if (hours > 0) { + return String.format(Locale.US, "%02d:%02d:%02d", hours, minutes, seconds); + } + return String.format(Locale.US, "%02d:%02d", minutes, seconds); + } +} diff --git a/app/src/main/java/com/tomcooks/reccoon/Marker.java b/app/src/main/java/com/tomcooks/reccoon/Marker.java new file mode 100644 index 0000000..a132cee --- /dev/null +++ b/app/src/main/java/com/tomcooks/reccoon/Marker.java @@ -0,0 +1,19 @@ +package com.tomcooks.reccoon; + +/** A user-placed marker inside a recording, stored as a WAV cue point. */ +public final class Marker { + + public final int index; + public final long sampleOffset; + public final String label; + + public Marker(int index, long sampleOffset, String label) { + this.index = index; + this.sampleOffset = sampleOffset; + this.label = label == null ? "" : label; + } + + public long timeMillis() { + return sampleOffset * 1000L / WavRecorder.SAMPLE_RATE; + } +} diff --git a/app/src/main/java/com/tomcooks/reccoon/MarkerStore.java b/app/src/main/java/com/tomcooks/reccoon/MarkerStore.java new file mode 100644 index 0000000..a84ff87 --- /dev/null +++ b/app/src/main/java/com/tomcooks/reccoon/MarkerStore.java @@ -0,0 +1,110 @@ +package com.tomcooks.reccoon; + +import android.content.Context; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * Stores recording markers as JSON sidecars (the same markers are also + * embedded in the WAV itself as {@code cue } / {@code adtl} chunks). + */ +public final class MarkerStore { + + private final File dir; + + public MarkerStore(Context context) { + dir = new File(context.getApplicationContext().getFilesDir(), "markers"); + } + + public boolean has(String recordingName) { + return fileFor(recordingName).isFile(); + } + + public void save(String recordingName, List markers) throws IOException { + if (markers.isEmpty()) { + return; + } + JSONArray array = new JSONArray(); + try { + for (Marker marker : markers) { + JSONObject item = new JSONObject(); + item.put("index", marker.index); + item.put("offset", marker.sampleOffset); + item.put("label", marker.label); + array.put(item); + } + } catch (JSONException e) { + throw new IOException(e); + } + + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("Cannot create marker directory"); + } + String json; + try { + json = array.toString(2); + } catch (JSONException e) { + throw new IOException(e); + } + try (FileOutputStream out = new FileOutputStream(fileFor(recordingName))) { + out.write(json.getBytes(StandardCharsets.UTF_8)); + } + } + + public List load(String recordingName) { + List markers = new ArrayList<>(); + File file = fileFor(recordingName); + if (!file.isFile()) { + return markers; + } + try (InputStream in = new java.io.FileInputStream(file)) { + byte[] buffer = new byte[(int) file.length()]; + int read = 0; + while (read < buffer.length) { + int n = in.read(buffer, read, buffer.length - read); + if (n < 0) { + break; + } + read += n; + } + JSONArray array = new JSONArray(new String(buffer, 0, read, StandardCharsets.UTF_8)); + for (int i = 0; i < array.length(); i++) { + JSONObject item = array.getJSONObject(i); + markers.add(new Marker( + item.optInt("index", i + 1), + item.optLong("offset", 0), + item.optString("label", ""))); + } + } catch (Exception e) { + // Return whatever we parsed. + } + return markers; + } + + public void delete(String recordingName) { + //noinspection ResultOfMethodCallIgnored + fileFor(recordingName).delete(); + } + + public void rename(String oldName, String newName) { + File from = fileFor(oldName); + if (from.isFile()) { + //noinspection ResultOfMethodCallIgnored + from.renameTo(fileFor(newName)); + } + } + + private File fileFor(String recordingName) { + return new File(dir, recordingName + ".json"); + } +} diff --git a/app/src/main/java/com/tomcooks/reccoon/ModelDownloadService.java b/app/src/main/java/com/tomcooks/reccoon/ModelDownloadService.java new file mode 100644 index 0000000..1cea837 --- /dev/null +++ b/app/src/main/java/com/tomcooks/reccoon/ModelDownloadService.java @@ -0,0 +1,213 @@ +package com.tomcooks.reccoon; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ServiceInfo; +import android.os.Build; +import android.os.IBinder; +import android.os.PowerManager; + +import androidx.core.app.NotificationCompat; +import androidx.core.content.ContextCompat; + +/** + * Downloads a transcription model in a foreground service so the transfer keeps + * going when the screen turns off or the user switches to another app. A partial + * wake lock keeps the CPU up for the network I/O. + */ +public class ModelDownloadService extends Service { + + public static final String ACTION_START = "com.tomcooks.reccoon.action.DOWNLOAD_MODEL"; + public static final String EXTRA_MODEL = "model"; + + private static final String CHANNEL_ID = "reccoon_model_download"; + private static final int NOTIFICATION_ID = 2; + private static final long WAKE_LOCK_TIMEOUT_MS = 2 * 60 * 60 * 1000L; + + public interface Listener { + void onProgress(long downloaded, long total, String currentFile); + + void onComplete(); + + void onError(String message); + } + + private static volatile Listener sListener; + private static volatile boolean sRunning; + private static volatile long sDownloaded; + private static volatile long sTotal; + private static volatile String sCurrentFile = ""; + + public static void setListener(Listener listener) { + sListener = listener; + } + + public static boolean isRunning() { + return sRunning; + } + + public static long downloaded() { + return sDownloaded; + } + + public static long total() { + return sTotal; + } + + public static String currentFile() { + return sCurrentFile; + } + + public static void start(Context context, ModelRepository.Model model) { + Intent intent = new Intent(context, ModelDownloadService.class) + .setAction(ACTION_START) + .putExtra(EXTRA_MODEL, model.name()); + ContextCompat.startForegroundService(context, intent); + } + + private PowerManager.WakeLock wakeLock; + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null || !ACTION_START.equals(intent.getAction())) { + stopSelf(); + return START_NOT_STICKY; + } + ModelRepository.Model model; + try { + model = ModelRepository.Model.valueOf(intent.getStringExtra(EXTRA_MODEL)); + } catch (Exception e) { + stopSelf(); + return START_NOT_STICKY; + } + + startForegroundCompat(getString(R.string.status_downloading, "", 0)); + + if (sRunning) { + // A download is already in flight; keep the existing one. + return START_NOT_STICKY; + } + sRunning = true; + sDownloaded = 0L; + sTotal = model.totalBytes(); + sCurrentFile = ""; + acquireWakeLock(); + + new Thread(() -> { + Listener listener = sListener; + try { + ModelRepository repository = new ModelRepository(this); + repository.download(model, (downloaded, total, file) -> { + sDownloaded = downloaded; + sTotal = total; + sCurrentFile = file; + int permille = (int) (downloaded * 1000 / Math.max(1, total)); + updateNotification(getString( + R.string.status_downloading, file, permille / 10), permille); + Listener current = sListener; + if (current != null) { + current.onProgress(downloaded, total, file); + } + }); + if (listener != null) { + listener.onComplete(); + } + } catch (Exception e) { + if (listener != null) { + listener.onError(String.valueOf(e.getMessage())); + } + } finally { + sRunning = false; + releaseWakeLock(); + stopForeground(true); + stopSelf(); + } + }, "reccoon-model-download").start(); + + return START_NOT_STICKY; + } + + private void startForegroundCompat(String text) { + Notification notification = buildNotification(text, 0); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC); + } else { + startForeground(NOTIFICATION_ID, notification); + } + } + + private void updateNotification(String text, int permille) { + NotificationManager manager = + (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + if (manager != null) { + manager.notify(NOTIFICATION_ID, buildNotification(text, permille)); + } + } + + private Notification buildNotification(String text, int permille) { + createChannel(); + return new NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.model_download_title)) + .setContentText(text) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setProgress(1000, permille, false) + .build(); + } + + private void createChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationManager manager = getSystemService(NotificationManager.class); + if (manager != null && manager.getNotificationChannel(CHANNEL_ID) == null) { + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + getString(R.string.model_download_channel), + NotificationManager.IMPORTANCE_LOW); + channel.setShowBadge(false); + manager.createNotificationChannel(channel); + } + } + } + + private void acquireWakeLock() { + try { + PowerManager power = (PowerManager) getSystemService(Context.POWER_SERVICE); + if (power != null) { + wakeLock = power.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, "reccoon:model-download"); + wakeLock.acquire(WAKE_LOCK_TIMEOUT_MS); + } + } catch (Exception ignored) { + // Without the wake lock the download may pause when the screen is off. + } + } + + private void releaseWakeLock() { + try { + if (wakeLock != null && wakeLock.isHeld()) { + wakeLock.release(); + } + } catch (Exception ignored) { + // Ignore. + } + wakeLock = null; + } + + @Override + public void onDestroy() { + releaseWakeLock(); + sRunning = false; + super.onDestroy(); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } +} diff --git a/app/src/main/java/com/tomcooks/reccoon/ModelRepository.java b/app/src/main/java/com/tomcooks/reccoon/ModelRepository.java new file mode 100644 index 0000000..1d0e0be --- /dev/null +++ b/app/src/main/java/com/tomcooks/reccoon/ModelRepository.java @@ -0,0 +1,367 @@ +package com.tomcooks.reccoon; + +import android.content.Context; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +/** + * Downloads and stores the on-device speech recognition models. + * + *

Currently only NVIDIA Parakeet TDT 0.6B v3 (int8) is offered: a + * multilingual transducer (~670 MB) that was clearly the most accurate model in + * the evaluation harness. The enum leaves room for more models later. + * + *

A small Silero VAD model splits long recordings into transcribable chunks. + */ +public final class ModelRepository { + + private static final String HF_PARAKEET = + "https://huggingface.co/csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8/resolve/main/"; + private static final String VAD_URL = + "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/silero_vad.onnx"; + + private static final long VAD_BYTES = 643_854L; + + /** A model the player can transcribe with. */ + public enum Model { + PARAKEET_V3("parakeet-v3", "Parakeet v3", + 652_184_281L, 11_845_275L, 6_355_277L, 93_939L); + + /** Folder under {@code filesDir/models} (or the external equivalent). */ + public final String dir; + public final String label; + final long encoderBytes; + final long decoderBytes; + final long joinerBytes; + final long tokensBytes; + + Model(String dir, String label, + long encoderBytes, long decoderBytes, long joinerBytes, long tokensBytes) { + this.dir = dir; + this.label = label; + this.encoderBytes = encoderBytes; + this.decoderBytes = decoderBytes; + this.joinerBytes = joinerBytes; + this.tokensBytes = tokensBytes; + } + + public long totalBytes() { + return encoderBytes + decoderBytes + joinerBytes + tokensBytes + VAD_BYTES; + } + } + + public interface Listener { + void onProgress(long downloaded, long total, String currentFile); + + default void onComplete() { + } + } + + private static final class RemoteFile { + final String url; + final File target; + final long expected; + + RemoteFile(String url, File target, long expected) { + this.url = url; + this.target = target; + this.expected = expected; + } + } + + private final File root; + + public ModelRepository(Context context) { + Context app = context.getApplicationContext(); + // Prefer the app-specific external folder (/sdcard/Android/data//files) + // so large models can be side-loaded with adb/scp or copied over USB. Fall + // back to internal storage when external storage is not mounted. + File external = app.getExternalFilesDir(null); + File base = external != null ? external : app.getFilesDir(); + root = new File(base, "models"); + migrateLegacyModels(base); + } + + /** + * Best-effort one-time migration from the old {@code com.tomcooks.reccoon} app + * id, so a re-branded install can reuse an already downloaded model instead + * of fetching it again. Android 11+ usually blocks access to another app's + * external directory; in that case this quietly does nothing and the model + * is downloaded again. + */ + private void migrateLegacyModels(File base) { + try { + File packageDir = base.getParentFile(); + File androidData = packageDir == null ? null : packageDir.getParentFile(); + if (androidData == null) { + return; + } + File legacy = new File( + new File(new File(androidData, "com.tomcooks.reccoon"), "files"), "models"); + if (legacy.isDirectory()) { + copyMissing(legacy, root); + } + } catch (Exception ignored) { + // Not readable (scoped storage); the model will simply be downloaded. + } + } + + private static void copyMissing(File from, File to) { + File[] children = from.listFiles(); + if (children == null) { + return; + } + if (!to.isDirectory() && !to.mkdirs()) { + return; + } + for (File child : children) { + File destination = new File(to, child.getName()); + if (child.isDirectory()) { + copyMissing(child, destination); + } else if (!destination.exists()) { + copyFile(child, destination); + } + } + } + + private static void copyFile(File from, File to) { + File temp = new File(to.getParentFile(), to.getName() + ".migrate"); + try (InputStream in = new FileInputStream(from); + FileOutputStream out = new FileOutputStream(temp)) { + byte[] buffer = new byte[256 * 1024]; + int read; + while ((read = in.read(buffer)) > 0) { + out.write(buffer, 0, read); + } + out.getFD().sync(); + //noinspection ResultOfMethodCallIgnored + temp.renameTo(to); + } catch (Exception ignored) { + //noinspection ResultOfMethodCallIgnored + temp.delete(); + } + } + + /** Folder that holds every model, for troubleshooting / manual side-loading. */ + public File modelsRoot() { + return root; + } + + public File vadModel() { + return new File(root, "silero_vad.onnx"); + } + + public File modelDir(Model model) { + return new File(root, model.dir); + } + + public File encoder(Model model) { + return new File(modelDir(model), "encoder.int8.onnx"); + } + + public File decoder(Model model) { + return new File(modelDir(model), "decoder.int8.onnx"); + } + + public File joiner(Model model) { + return new File(modelDir(model), "joiner.int8.onnx"); + } + + public File tokens(Model model) { + return new File(modelDir(model), "tokens.txt"); + } + + public boolean isReady(Model model) { + return isVadReady() && allComplete(files(model)); + } + + public boolean isVadReady() { + return isComplete(vadModel(), VAD_BYTES); + } + + private static boolean isComplete(File file, long expected) { + return file.isFile() && (expected <= 0 || file.length() == expected); + } + + private List files(Model model) { + List files = new ArrayList<>(); + files.add(new RemoteFile(VAD_URL, vadModel(), VAD_BYTES)); + files.add(new RemoteFile(HF_PARAKEET + "encoder.int8.onnx", + encoder(model), model.encoderBytes)); + files.add(new RemoteFile(HF_PARAKEET + "decoder.int8.onnx", + decoder(model), model.decoderBytes)); + files.add(new RemoteFile(HF_PARAKEET + "joiner.int8.onnx", + joiner(model), model.joinerBytes)); + files.add(new RemoteFile(HF_PARAKEET + "tokens.txt", + tokens(model), model.tokensBytes)); + return files; + } + + private boolean allComplete(List files) { + for (RemoteFile file : files) { + if (!isComplete(file.target, file.expected)) { + return false; + } + } + return true; + } + + /** + * Blocking. Call from a background thread. Progress is reported through + * {@code listener}. Throws on failure. + */ + public void download(Model model, Listener listener) throws IOException { + List files = files(model); + long total = 0; + long already = 0; + for (RemoteFile file : files) { + total += file.expected; + if (isComplete(file.target, file.expected)) { + already += file.expected; + } + } + + Counter counter = new Counter(already, total, listener); + counter.report(""); + for (RemoteFile file : files) { + fetch(file.url, file.target, file.expected, counter); + } + listener.onComplete(); + } + + private static final class Counter { + private final long already; + private final long total; + private final Listener listener; + private long doneInCurrent; + private String currentName = ""; + + Counter(long already, long total, Listener listener) { + this.already = already; + this.total = total; + this.listener = listener; + } + + void setCurrent(String name, long done) { + this.currentName = name; + this.doneInCurrent = done; + listener.onProgress(Math.min(already + done, total), total, currentName); + } + + void report(String name) { + listener.onProgress(Math.min(already + doneInCurrent, total), total, name); + } + } + + private static final int MAX_ATTEMPTS = 6; + + private static void fetch(String url, File target, long expected, Counter counter) + throws IOException { + if (isComplete(target, expected)) { + return; + } + File parent = target.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + throw new IOException("Cannot create " + parent); + } + File part = new File(parent, target.getName() + ".part"); + + IOException lastError = null; + for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + downloadOnce(url, target, part, expected, counter); + return; + } catch (IOException e) { + lastError = e; + // Keep the .part file: the next attempt resumes with a Range request. + sleepQuietly(attempt); + } + } + throw lastError != null ? lastError : new IOException("Download failed: " + url); + } + + private static void downloadOnce(String url, File target, File part, long expected, + Counter counter) throws IOException { + long already = part.isFile() ? part.length() : 0L; + if (expected > 0 && already >= expected) { + if (already == expected) { + finish(part, target, expected); + return; + } + //noinspection ResultOfMethodCallIgnored + part.delete(); + already = 0L; + } + + HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setInstanceFollowRedirects(true); + connection.setConnectTimeout(30_000); + connection.setReadTimeout(120_000); + connection.setRequestProperty("User-Agent", "RECCoon/1.0 (Android)"); + connection.setRequestProperty("Accept-Encoding", "identity"); + if (already > 0) { + connection.setRequestProperty("Range", "bytes=" + already + "-"); + } + try { + int code = connection.getResponseCode(); + if (code == HttpURLConnection.HTTP_OK) { + // The server ignored the Range request: start the file over. + already = 0L; + } else if (code != HttpURLConnection.HTTP_PARTIAL) { + throw new IOException("HTTP " + code + " for " + url); + } + + try (InputStream in = connection.getInputStream(); + FileOutputStream out = new FileOutputStream(part, already > 0)) { + byte[] buffer = new byte[256 * 1024]; + long done = already; + int read; + long lastReport = 0; + while ((read = in.read(buffer)) > 0) { + out.write(buffer, 0, read); + done += read; + long now = System.currentTimeMillis(); + if (now - lastReport > 200) { + lastReport = now; + counter.setCurrent(target.getName(), done); + } + } + out.getFD().sync(); + counter.setCurrent(target.getName(), done); + } + + finish(part, target, expected); + } finally { + connection.disconnect(); + } + } + + private static void finish(File part, File target, long expected) throws IOException { + if (expected > 0 && part.length() != expected) { + throw new IOException("Incomplete download for " + target.getName() + + " (have " + part.length() + " of " + expected + " bytes)"); + } + if (target.exists() && !target.delete()) { + throw new IOException("Cannot replace " + target); + } + if (!part.renameTo(target)) { + throw new IOException("Cannot move download into place: " + target); + } + } + + private static void sleepQuietly(int attempt) { + try { + Thread.sleep(Math.min(8_000L, 500L * (1L << Math.min(attempt, 4)))); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/app/src/main/java/com/tomcooks/reccoon/PcmAudioSource.java b/app/src/main/java/com/tomcooks/reccoon/PcmAudioSource.java new file mode 100644 index 0000000..9987d09 --- /dev/null +++ b/app/src/main/java/com/tomcooks/reccoon/PcmAudioSource.java @@ -0,0 +1,239 @@ +package com.tomcooks.reccoon; + +import android.content.Context; +import android.media.MediaCodec; +import android.media.MediaExtractor; +import android.media.MediaFormat; +import android.net.Uri; + +import java.io.BufferedInputStream; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; + +/** + * A streaming source of 16-bit PCM samples. + * + *

Handles both the uncompressed WAV files that RECCoon records and the + * compressed files (AAC/M4A, Opus, ...) that the compact mode produces. WAV is + * parsed directly; everything else is decoded with {@link MediaCodec}. This is + * what lets transcription keep working on compressed recordings. + */ +public final class PcmAudioSource implements Closeable { + + private static final long CODEC_TIMEOUT_US = 10_000; + + /** Sample rate of the returned PCM. */ + public volatile int sampleRate; + /** Number of interleaved channels in the returned PCM. */ + public volatile int channels; + /** PCM size in bytes at {@link #sampleRate}, or 0 when unknown. */ + public final long totalBytes; + + private final InputStream wav; + private long wavRemaining; + + private final MediaExtractor extractor; + private final MediaCodec codec; + private boolean inputDone; + private boolean outputDone; + private byte[] pending; + private int pendingPos; + private int pendingLen; + + private PcmAudioSource(int sampleRate, int channels, long totalBytes, + InputStream wav, long wavRemaining, + MediaExtractor extractor, MediaCodec codec) { + this.sampleRate = sampleRate; + this.channels = channels; + this.totalBytes = totalBytes; + this.wav = wav; + this.wavRemaining = wavRemaining; + this.extractor = extractor; + this.codec = codec; + } + + /** + * Opens a WAV file when possible, otherwise falls back to a compressed + * decoder. Throws when neither path can read the URI. + */ + public static PcmAudioSource open(Context context, Uri uri) throws IOException { + InputStream raw = context.getContentResolver().openInputStream(uri); + if (raw == null) { + throw new IOException("Cannot open " + uri); + } + BufferedInputStream buffered = new BufferedInputStream(raw, 256 * 1024); + try { + TranscriptionEngine.WavInfo info = TranscriptionEngine.readWavHeader(buffered); + if (info.bitsPerSample != 16 || info.audioFormat != 1) { + throw new IOException("Only 16-bit PCM WAV files are supported"); + } + long remaining = info.dataSize > 0 ? info.dataSize : Long.MAX_VALUE; + return new PcmAudioSource(Math.max(1, info.sampleRate), Math.max(1, info.channels), + info.dataSize, buffered, remaining, null, null); + } catch (IOException notWav) { + closeQuietly(buffered); + } + return openCodec(context, uri); + } + + private static PcmAudioSource openCodec(Context context, Uri uri) throws IOException { + MediaExtractor extractor = new MediaExtractor(); + try { + extractor.setDataSource(context, uri, null); + } catch (IOException e) { + extractor.release(); + throw new IOException("Unsupported audio file: " + uri, e); + } + + int track = -1; + MediaFormat format = null; + for (int i = 0; i < extractor.getTrackCount(); i++) { + MediaFormat candidate = extractor.getTrackFormat(i); + String mime = candidate.getString(MediaFormat.KEY_MIME); + if (mime != null && mime.startsWith("audio/")) { + track = i; + format = candidate; + break; + } + } + if (track < 0 || format == null) { + extractor.release(); + throw new IOException("No audio track in " + uri); + } + extractor.selectTrack(track); + + String mime = format.getString(MediaFormat.KEY_MIME); + int sampleRate = format.containsKey(MediaFormat.KEY_SAMPLE_RATE) + ? format.getInteger(MediaFormat.KEY_SAMPLE_RATE) : 0; + int channels = format.containsKey(MediaFormat.KEY_CHANNEL_COUNT) + ? format.getInteger(MediaFormat.KEY_CHANNEL_COUNT) : 1; + long durationUs = format.containsKey(MediaFormat.KEY_DURATION) + ? format.getLong(MediaFormat.KEY_DURATION) : 0L; + long totalBytes = sampleRate > 0 + ? durationUs * sampleRate * Math.max(1, channels) * 2 / 1_000_000L : 0L; + + MediaCodec codec; + try { + codec = MediaCodec.createDecoderByType(mime); + codec.configure(format, null, null, 0); + codec.start(); + } catch (Exception e) { + extractor.release(); + throw new IOException("Cannot decode " + mime, e); + } + return new PcmAudioSource(sampleRate, Math.max(1, channels), totalBytes, + null, 0L, extractor, codec); + } + + /** + * Reads up to {@code length} bytes of interleaved 16-bit PCM. Returns the + * number of bytes read, or -1 at end of stream. + */ + public int read(byte[] out, int offset, int length) throws IOException { + if (wav != null) { + if (wavRemaining <= 0) { + return -1; + } + int toRead = (int) Math.min(length, wavRemaining); + int total = 0; + while (total < toRead) { + int n = wav.read(out, offset + total, toRead - total); + if (n < 0) { + break; + } + total += n; + } + wavRemaining -= total; + return total == 0 ? -1 : total; + } + + int written = 0; + while (written < length) { + if (pending != null && pendingPos < pendingLen) { + int n = Math.min(length - written, pendingLen - pendingPos); + System.arraycopy(pending, pendingPos, out, offset + written, n); + pendingPos += n; + written += n; + continue; + } + if (outputDone) { + break; + } + decodeStep(); + } + return written == 0 ? -1 : written; + } + + private void decodeStep() throws IOException { + if (!inputDone) { + int inIndex = codec.dequeueInputBuffer(CODEC_TIMEOUT_US); + if (inIndex >= 0) { + ByteBuffer input = codec.getInputBuffer(inIndex); + int size = input == null ? -1 : extractor.readSampleData(input, 0); + if (size < 0) { + codec.queueInputBuffer(inIndex, 0, 0, 0, + MediaCodec.BUFFER_FLAG_END_OF_STREAM); + inputDone = true; + } else { + codec.queueInputBuffer(inIndex, 0, size, extractor.getSampleTime(), 0); + extractor.advance(); + } + } + } + + MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); + int outIndex = codec.dequeueOutputBuffer(info, CODEC_TIMEOUT_US); + if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + MediaFormat output = codec.getOutputFormat(); + if (output.containsKey(MediaFormat.KEY_SAMPLE_RATE)) { + sampleRate = output.getInteger(MediaFormat.KEY_SAMPLE_RATE); + } + if (output.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) { + channels = Math.max(1, output.getInteger(MediaFormat.KEY_CHANNEL_COUNT)); + } + } else if (outIndex >= 0) { + if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { + outputDone = true; + } + ByteBuffer output = codec.getOutputBuffer(outIndex); + if (info.size > 0 && output != null) { + byte[] chunk = new byte[info.size]; + output.position(info.offset); + output.limit(info.offset + info.size); + output.get(chunk); + pending = chunk; + pendingPos = 0; + pendingLen = info.size; + } + codec.releaseOutputBuffer(outIndex, false); + } + } + + @Override + public void close() { + closeQuietly(wav); + if (extractor != null) { + extractor.release(); + } + if (codec != null) { + try { + codec.stop(); + } catch (Exception ignored) { + // Ignore. + } + codec.release(); + } + } + + private static void closeQuietly(InputStream stream) { + if (stream != null) { + try { + stream.close(); + } catch (IOException ignored) { + // Ignore. + } + } + } +} diff --git a/app/src/main/java/com/tomcooks/reccoon/PlayerActivity.java b/app/src/main/java/com/tomcooks/reccoon/PlayerActivity.java new file mode 100644 index 0000000..6d7d3f4 --- /dev/null +++ b/app/src/main/java/com/tomcooks/reccoon/PlayerActivity.java @@ -0,0 +1,647 @@ +package com.tomcooks.reccoon; + +import android.content.Intent; +import android.graphics.Color; +import android.media.MediaPlayer; +import android.media.PlaybackParams; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextPaint; +import android.text.method.LinkMovementMethod; +import android.text.style.ClickableSpan; +import android.view.View; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.ProgressBar; +import android.widget.SeekBar; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; + +import java.io.File; +import java.util.List; +import java.util.Locale; + +/** + * Plays a saved recording with a scrubber and transcribes it on device with + * sherpa-onnx (multilingual Whisper: English and Italian). + */ +public class PlayerActivity extends AppCompatActivity { + + public static final String EXTRA_NAME = "name"; + public static final String EXTRA_URI = "uri"; + + private static final float[] SPEEDS = {1.0f, 1.5f, 2.0f, 0.5f}; + + private String recordingName; + private Uri recordingUri; + + private MediaPlayer player; + private boolean prepared; + + private TextView titleText; + private TextView timeText; + private TextView statusText; + private SeekBar seekBar; + private Button playButton; + private Button speedButton; + private Button transcribeButton; + private Spinner modelSpinner; + private ProgressBar progressBar; + private LinearLayout transcriptContainer; + private LinearLayout markersSection; + private LinearLayout markersContainer; + + private final Handler handler = new Handler(Looper.getMainLooper()); + private boolean userSeeking; + private float speed = SPEEDS[0]; + private int speedIndex; + private volatile boolean cancelled; + + private ModelRepository models; + private TranscriptStore transcripts; + private MarkerStore markerStore; + private ModelRepository.Model downloadingModel; + + private final ModelDownloadService.Listener downloadListener = + new ModelDownloadService.Listener() { + @Override + public void onProgress(long downloaded, long total, String file) { + runOnUiThread(() -> { + int permille = (int) (downloaded * 1000 / Math.max(1, total)); + progressBar.setIndeterminate(false); + progressBar.setProgress(permille); + statusText.setText(getString( + R.string.status_downloading, file, permille / 10)); + }); + } + + @Override + public void onComplete() { + runOnUiThread(() -> { + statusText.setText(R.string.status_model_ready); + if (downloadingModel != null) { + runTranscription(downloadingModel); + } + }); + } + + @Override + public void onError(String message) { + runOnUiThread(() -> { + transcribeButton.setEnabled(true); + progressBar.setVisibility(View.GONE); + statusText.setText(getString(R.string.err_download, message)); + }); + } + }; + + private final Runnable ticker = new Runnable() { + @Override + public void run() { + updateProgress(); + handler.postDelayed(this, 250); + } + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_player); + + Intent intent = getIntent(); + recordingName = intent.getStringExtra(EXTRA_NAME); + String uriString = intent.getStringExtra(EXTRA_URI); + if (recordingName == null || uriString == null) { + finish(); + return; + } + recordingUri = Uri.parse(uriString); + + models = new ModelRepository(this); + transcripts = new TranscriptStore(this); + markerStore = new MarkerStore(this); + + titleText = findViewById(R.id.titleText); + timeText = findViewById(R.id.timeText); + statusText = findViewById(R.id.statusText); + seekBar = findViewById(R.id.seekBar); + playButton = findViewById(R.id.playButton); + speedButton = findViewById(R.id.speedButton); + transcribeButton = findViewById(R.id.transcribeButton); + modelSpinner = findViewById(R.id.modelSpinner); + progressBar = findViewById(R.id.progressBar); + transcriptContainer = findViewById(R.id.transcriptContainer); + markersSection = findViewById(R.id.markersSection); + markersContainer = findViewById(R.id.markersContainer); + + titleText.setText(Formats.prettyName(recordingName)); + + setUpSpinners(); + setUpButtons(); + setUpPlayer(); + + InsetsHelper.apply(findViewById(R.id.root)); + showMarkers(loadMarkers()); + + TranscriptionEngine.Result existing = transcripts.load(recordingName); + if (existing != null) { + showTranscript(existing); + } else { + statusText.setText(R.string.transcribe_hint); + } + + handler.post(ticker); + } + + private void setUpSpinners() { + ModelRepository.Model[] allModels = ModelRepository.Model.values(); + String[] modelLabels = new String[allModels.length]; + for (int i = 0; i < allModels.length; i++) { + modelLabels[i] = allModels[i].label; + } + ArrayAdapter modelAdapter = new ArrayAdapter<>(this, + android.R.layout.simple_spinner_item, modelLabels); + modelAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + modelSpinner.setAdapter(modelAdapter); + } + + private void setUpButtons() { + playButton.setOnClickListener(v -> togglePlay()); + speedButton.setOnClickListener(v -> cycleSpeed()); + findViewById(R.id.rewindButton).setOnClickListener(v -> seekBy(-10_000)); + findViewById(R.id.forwardButton).setOnClickListener(v -> seekBy(10_000)); + findViewById(R.id.shareButton).setOnClickListener(v -> shareRecording()); + findViewById(R.id.renameButton).setOnClickListener(v -> showRenameDialog()); + findViewById(R.id.exportButton).setOnClickListener(v -> exportTranscript()); + findViewById(R.id.deleteButton).setOnClickListener(v -> confirmDelete()); + transcribeButton.setOnClickListener(v -> onTranscribeClicked()); + + seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar bar, int progress, boolean fromUser) { + if (fromUser) { + timeText.setText(Formats.clock(progress) + " / " + Formats.clock(duration())); + } + } + + @Override + public void onStartTrackingTouch(SeekBar bar) { + userSeeking = true; + } + + @Override + public void onStopTrackingTouch(SeekBar bar) { + userSeeking = false; + if (player != null && prepared) { + player.seekTo(bar.getProgress()); + } + } + }); + } + + private void setUpPlayer() { + player = new MediaPlayer(); + prepared = false; + try { + player.setDataSource(this, recordingUri); + player.setOnPreparedListener(mp -> { + prepared = true; + seekBar.setMax(mp.getDuration()); + updateTimeLabel(); + updatePlayButton(); // no autoplay + }); + player.setOnCompletionListener(mp -> updatePlayButton()); + player.prepareAsync(); + } catch (Exception e) { + Toast.makeText(this, R.string.err_playback, Toast.LENGTH_LONG).show(); + } + } + + private int duration() { + if (player == null || !prepared) { + return 0; + } + try { + return player.getDuration(); + } catch (Exception e) { + return 0; + } + } + + private void togglePlay() { + if (player == null || !prepared) { + return; + } + try { + if (player.isPlaying()) { + player.pause(); + } else { + if (player.getCurrentPosition() >= player.getDuration() - 150) { + player.seekTo(0); + } + player.start(); + applySpeed(); + } + } catch (Exception ignored) { + // Ignore. + } + updatePlayButton(); + } + + private void seekBy(int deltaMs) { + if (player == null || !prepared) { + return; + } + int target = Math.max(0, Math.min(player.getCurrentPosition() + deltaMs, player.getDuration())); + player.seekTo(target); + seekBar.setProgress(target); + updateTimeLabel(); + } + + private void cycleSpeed() { + speedIndex = (speedIndex + 1) % SPEEDS.length; + speed = SPEEDS[speedIndex]; + speedButton.setText(String.format(java.util.Locale.US, "%.1f×", speed)); + applySpeed(); + } + + private void applySpeed() { + if (player == null || !prepared) { + return; + } + try { + PlaybackParams params = player.getPlaybackParams(); + params.setSpeed(speed); + player.setPlaybackParams(params); + } catch (Exception ignored) { + // Some devices reject speed changes while paused. + } + } + + private void updateProgress() { + if (player == null || !prepared) { + return; + } + try { + if (!userSeeking) { + seekBar.setProgress(player.getCurrentPosition()); + } + } catch (Exception ignored) { + // Ignore. + } + updateTimeLabel(); + updatePlayButton(); + } + + private void updateTimeLabel() { + timeText.setText(Formats.clock(player != null && prepared ? player.getCurrentPosition() : 0) + + " / " + Formats.clock(duration())); + } + + private void updatePlayButton() { + boolean playing = false; + if (player != null && prepared) { + try { + playing = player.isPlaying(); + } catch (Exception ignored) { + // Ignore. + } + } + playButton.setText(playing ? R.string.player_pause : R.string.player_play); + } + + // --- Library actions -------------------------------------------------- + + private List loadMarkers() { + List markers = markerStore.load(recordingName); + if (markers.isEmpty()) { + markers = WavMarkers.read(this, recordingUri); + } + return markers; + } + + private void shareRecording() { + try { + Uri shareUri = RecordingActions.shareableUri(this, recordingUri); + Intent intent = new Intent(Intent.ACTION_SEND); + intent.setType(recordingName.toLowerCase(Locale.US).endsWith(".m4a") + ? "audio/mp4" : "audio/wav"); + intent.putExtra(Intent.EXTRA_STREAM, shareUri); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startActivity(Intent.createChooser(intent, getString(R.string.action_share))); + } catch (Exception e) { + Toast.makeText(this, R.string.err_share, Toast.LENGTH_LONG).show(); + } + } + + private void confirmDelete() { + new AlertDialog.Builder(this) + .setTitle(R.string.delete_title) + .setMessage(getString(R.string.delete_message, Formats.prettyName(recordingName))) + .setPositiveButton(R.string.action_delete, (dialog, which) -> { + RecordingActions.delete(this, recordingUri, recordingName); + Toast.makeText(this, R.string.deleted, Toast.LENGTH_SHORT).show(); + finish(); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + } + + private void showRenameDialog() { + EditText input = new EditText(this); + input.setText(recordingName); + input.selectAll(); + + int padding = (int) (20 * getResources().getDisplayMetrics().density); + FrameLayout container = new FrameLayout(this); + container.setPadding(padding, padding / 2, padding, 0); + container.addView(input); + + new AlertDialog.Builder(this) + .setTitle(R.string.rename_title) + .setView(container) + .setPositiveButton(R.string.action_rename, (dialog, which) -> { + String requested = input.getText().toString().trim(); + String lower = requested.toLowerCase(Locale.US); + if (!lower.endsWith(".wav") && !lower.endsWith(".m4a")) { + requested += recordingName.toLowerCase(Locale.US).endsWith(".m4a") + ? ".m4a" : ".wav"; + } + String renamed = RecordingActions.rename( + this, recordingUri, recordingName, requested); + if (renamed == null) { + Toast.makeText(this, R.string.rename_failed, Toast.LENGTH_LONG).show(); + return; + } + if ("file".equals(recordingUri.getScheme())) { + File parent = new File(recordingUri.getPath()).getParentFile(); + recordingUri = Uri.fromFile(new File(parent, renamed)); + } + recordingName = renamed; + titleText.setText(Formats.prettyName(recordingName)); + showMarkers(loadMarkers()); + Toast.makeText(this, R.string.renamed, Toast.LENGTH_SHORT).show(); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + } + + private void exportTranscript() { + TranscriptionEngine.Result result = transcripts.load(recordingName); + if (result == null || result.segments.isEmpty()) { + Toast.makeText(this, R.string.export_none, Toast.LENGTH_LONG).show(); + return; + } + try { + RecordingActions.exportTranscript(this, recordingName, result); + Toast.makeText(this, R.string.exported, Toast.LENGTH_LONG).show(); + } catch (Exception e) { + Toast.makeText(this, getString(R.string.export_failed, String.valueOf(e.getMessage())), + Toast.LENGTH_LONG).show(); + } + } + + // --- Transcription ---------------------------------------------------- + + private void onTranscribeClicked() { + ModelRepository.Model[] allModels = ModelRepository.Model.values(); + int position = modelSpinner.getSelectedItemPosition(); + if (position < 0 || position >= allModels.length) { + position = 0; + } + ModelRepository.Model model = allModels[position]; + + if (!models.isReady(model)) { + promptDownload(model); + } else { + runTranscription(model); + } + } + + private void promptDownload(ModelRepository.Model model) { + new AlertDialog.Builder(this) + .setTitle(R.string.model_download_title) + .setMessage(getString(R.string.model_download_message, + model.label, Formats.humanBytes(model.totalBytes()))) + .setPositiveButton(R.string.download, (dialog, which) -> runDownload(model)) + .setNegativeButton(R.string.cancel, null) + .show(); + } + + private void runDownload(ModelRepository.Model model) { + downloadingModel = model; + transcribeButton.setEnabled(false); + progressBar.setVisibility(View.VISIBLE); + progressBar.setIndeterminate(false); + progressBar.setProgress(0); + statusText.setText(R.string.status_preparing); + // The download runs in a foreground service so it survives the screen + // turning off or the app losing focus. + ModelDownloadService.setListener(downloadListener); + ModelDownloadService.start(this, model); + } + + private void runTranscription(ModelRepository.Model model) { + cancelled = false; + transcribeButton.setEnabled(false); + transcriptContainer.removeAllViews(); + progressBar.setVisibility(View.VISIBLE); + progressBar.setIndeterminate(true); + statusText.setText(R.string.status_preparing); + + new Thread(() -> { + TranscriptionEngine.Result result; + try { + TranscriptionEngine engine = new TranscriptionEngine(PlayerActivity.this); + result = engine.transcribe(recordingUri, model, + new TranscriptionEngine.Callback() { + @Override + public void onProgress(int percent, String message) { + runOnUiThread(() -> { + progressBar.setIndeterminate(false); + progressBar.setProgress(percent * 10); + statusText.setText(message); + }); + } + + @Override + public boolean isCancelled() { + return cancelled; + } + }); + } catch (Exception e) { + runOnUiThread(() -> statusText.setText( + getString(R.string.err_transcribe, String.valueOf(e.getMessage())))); + return; + } finally { + runOnUiThread(() -> { + transcribeButton.setEnabled(true); + progressBar.setVisibility(View.GONE); + }); + } + + if (result == null) { + return; + } + try { + transcripts.save(recordingName, result); + } catch (Exception ignored) { + // The transcript is still shown below. + } + runOnUiThread(() -> showTranscript(result)); + }, "transcribe").start(); + } + + private void showTranscript(TranscriptionEngine.Result result) { + transcriptContainer.removeAllViews(); + if (result.segments.isEmpty()) { + statusText.setText(R.string.status_no_speech); + return; + } + for (TranscriptionEngine.Segment segment : result.segments) { + addTranscriptLine(segment); + } + statusText.setText(R.string.status_transcript_saved); + } + + private void addTranscriptLine(TranscriptionEngine.Segment segment) { + SpannableStringBuilder builder = new SpannableStringBuilder(); + builder.append("[").append(Formats.clock((int) (segment.startSeconds * 1000))).append("] "); + + if (segment.words.isEmpty()) { + builder.append(segment.text); + } else { + for (TranscriptionEngine.Word word : segment.words) { + int start = builder.length(); + builder.append(word.text); + int end = builder.length(); + if (end > start) { + final int seekMs = (int) (word.startSeconds * 1000); + builder.setSpan(new ClickableSpan() { + @Override + public void onClick(@NonNull View widget) { + seekTo(seekMs); + } + + @Override + public void updateDrawState(@NonNull TextPaint ds) { + ds.setColor(getResources().getColor(R.color.text_primary, getTheme())); + ds.setUnderlineText(false); + } + }, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + } + + TextView view = new TextView(this); + view.setText(builder); + view.setTextColor(getResources().getColor(R.color.text_primary, getTheme())); + view.setTextSize(15f); + view.setPadding(0, 12, 0, 12); + view.setMovementMethod(LinkMovementMethod.getInstance()); + view.setHighlightColor(Color.TRANSPARENT); + view.setOnClickListener(v -> seekTo((int) (segment.startSeconds * 1000))); + transcriptContainer.addView(view); + } + + private void showMarkers(List markers) { + markersContainer.removeAllViews(); + markersSection.setVisibility(markers.isEmpty() ? View.GONE : View.VISIBLE); + float density = getResources().getDisplayMetrics().density; + for (Marker marker : markers) { + TextView chip = new TextView(this); + StringBuilder text = new StringBuilder(); + text.append('M').append(marker.index) + .append(" ").append(Formats.clock((int) marker.timeMillis())); + if (!marker.label.isEmpty()) { + text.append(" ").append(marker.label); + } + chip.setText(text.toString()); + chip.setTextColor(getResources().getColor(R.color.text_primary, getTheme())); + chip.setTextSize(13f); + chip.setPadding((int) (12 * density), (int) (6 * density), + (int) (12 * density), (int) (6 * density)); + chip.setBackgroundResource(R.drawable.badge_marker); + chip.setOnClickListener(v -> seekTo((int) marker.timeMillis())); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT); + params.rightMargin = (int) (8 * density); + chip.setLayoutParams(params); + markersContainer.addView(chip); + } + } + + private void seekTo(int millis) { + if (player == null || !prepared) { + return; + } + int target = Math.max(0, Math.min(millis, player.getDuration())); + player.seekTo(target); + seekBar.setProgress(target); + if (!player.isPlaying()) { + player.start(); + applySpeed(); + } + updateTimeLabel(); + } + + @Override + protected void onResume() { + super.onResume(); + if (ModelDownloadService.isRunning()) { + ModelDownloadService.setListener(downloadListener); + progressBar.setVisibility(View.VISIBLE); + progressBar.setIndeterminate(false); + long done = ModelDownloadService.downloaded(); + long total = ModelDownloadService.total(); + int permille = (int) (done * 1000 / Math.max(1, total)); + progressBar.setProgress(permille); + statusText.setText(getString(R.string.status_downloading, + ModelDownloadService.currentFile(), permille / 10)); + } + } + + @Override + protected void onPause() { + super.onPause(); + if (player != null && prepared) { + try { + if (player.isPlaying()) { + player.pause(); + } + } catch (Exception ignored) { + // Ignore. + } + updatePlayButton(); + } + } + + @Override + protected void onDestroy() { + cancelled = true; + ModelDownloadService.setListener(null); + handler.removeCallbacksAndMessages(null); + if (player != null) { + try { + player.release(); + } catch (Exception ignored) { + // Ignore. + } + player = null; + } + super.onDestroy(); + } +} diff --git a/app/src/main/java/com/tomcooks/reccoon/RecordingActions.java b/app/src/main/java/com/tomcooks/reccoon/RecordingActions.java new file mode 100644 index 0000000..3a110eb --- /dev/null +++ b/app/src/main/java/com/tomcooks/reccoon/RecordingActions.java @@ -0,0 +1,154 @@ +package com.tomcooks.reccoon; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.media.MediaScannerConnection; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.MediaStore; + +import androidx.core.content.FileProvider; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Locale; + +/** Delete / rename / share helpers for saved recordings. */ +public final class RecordingActions { + + private RecordingActions() { + } + + public static void delete(Context context, Uri uri, String name) { + try { + if ("file".equals(uri.getScheme())) { + //noinspection ResultOfMethodCallIgnored + new File(uri.getPath()).delete(); + } else { + context.getContentResolver().delete(uri, null, null); + } + } catch (Exception ignored) { + // Keep going: also remove the sidecars. + } + new TranscriptStore(context).delete(name); + new MarkerStore(context).delete(name); + } + + /** @return the new file name, or null if renaming failed. */ + public static String rename(Context context, Uri uri, String oldName, String newName) { + if (newName == null || newName.trim().isEmpty()) { + return null; + } + newName = newName.trim(); + String lower = newName.toLowerCase(Locale.US); + if (!lower.endsWith(".wav") && !lower.endsWith(".m4a")) { + return null; + } + String mime = lower.endsWith(".m4a") ? "audio/mp4" : "audio/wav"; + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !"file".equals(uri.getScheme())) { + ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.DISPLAY_NAME, newName); + context.getContentResolver().update(uri, values, null, null); + } else { + File oldFile = new File(uri.getPath()); + File newFile = new File(oldFile.getParentFile(), newName); + if (!oldFile.renameTo(newFile)) { + return null; + } + MediaScannerConnection.scanFile(context, + new String[]{newFile.getAbsolutePath()}, + new String[]{mime}, null); + } + } catch (Exception e) { + return null; + } + new TranscriptStore(context).rename(oldName, newName); + new MarkerStore(context).rename(oldName, newName); + return newName; + } + + /** A URI that can be handed to other apps (FileProvider for file:// paths). */ + public static Uri shareableUri(Context context, Uri uri) { + if ("file".equals(uri.getScheme())) { + File file = new File(uri.getPath()); + return FileProvider.getUriForFile( + context, context.getPackageName() + ".fileprovider", file); + } + return uri; + } + + public static void exportTranscript(Context context, String recordingName, + TranscriptionEngine.Result result) throws IOException { + if (result == null || result.segments.isEmpty()) { + throw new IOException("Nothing to export"); + } + String stem = (recordingName.endsWith(".wav") || recordingName.endsWith(".m4a")) + ? recordingName.substring(0, recordingName.length() - 4) + : recordingName; + writeToDownloads(context, stem + ".txt", "text/plain", result.asText()); + writeToDownloads(context, stem + ".srt", "application/x-subrip", toSrt(result)); + } + + public static String toSrt(TranscriptionEngine.Result result) { + StringBuilder sb = new StringBuilder(); + int index = 1; + for (TranscriptionEngine.Segment segment : result.segments) { + sb.append(index++).append('\n'); + sb.append(srtTime(segment.startSeconds)).append(" --> ") + .append(srtTime(segment.endSeconds)).append('\n'); + sb.append(segment.text.trim()).append("\n\n"); + } + return sb.toString(); + } + + private static String srtTime(float seconds) { + int total = Math.round(Math.max(0f, seconds) * 1000f); + int millis = total % 1000; + int secs = (total / 1000) % 60; + int mins = (total / 60000) % 60; + int hours = total / 3600000; + return String.format(Locale.US, "%02d:%02d:%02d,%03d", hours, mins, secs, millis); + } + + private static void writeToDownloads(Context context, String name, String mime, String content) + throws IOException { + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ContentResolver resolver = context.getContentResolver(); + ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.DISPLAY_NAME, name); + values.put(MediaStore.MediaColumns.MIME_TYPE, mime); + values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS); + values.put(MediaStore.MediaColumns.IS_PENDING, 1); + Uri uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values); + if (uri == null) { + throw new IOException("MediaStore rejected " + name); + } + try (OutputStream out = resolver.openOutputStream(uri)) { + if (out == null) { + throw new IOException("No output stream for " + name); + } + out.write(bytes); + } + ContentValues done = new ContentValues(); + done.put(MediaStore.MediaColumns.IS_PENDING, 0); + resolver.update(uri, done, null, null); + } else { + File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("Could not create Downloads"); + } + File out = new File(dir, name); + try (FileOutputStream fos = new FileOutputStream(out)) { + fos.write(bytes); + } + MediaScannerConnection.scanFile(context, new String[]{out.getAbsolutePath()}, null, null); + } + } +} diff --git a/app/src/main/java/com/tomcooks/reccoon/RecordingService.java b/app/src/main/java/com/tomcooks/reccoon/RecordingService.java new file mode 100644 index 0000000..28bf803 --- /dev/null +++ b/app/src/main/java/com/tomcooks/reccoon/RecordingService.java @@ -0,0 +1,68 @@ +package com.tomcooks.reccoon; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.Service; +import android.content.Intent; +import android.content.pm.ServiceInfo; +import android.os.Build; +import android.os.IBinder; + +import androidx.core.app.NotificationCompat; + +/** + * Keeps the process alive (and the microphone accessible) while a recording is + * in progress, so recording continues when the app goes to the background. + */ +public class RecordingService extends Service { + + private static final String CHANNEL_ID = "reccoon_recording"; + private static final int NOTIFICATION_ID = 1; + + public static final String ACTION_START = "com.tomcooks.reccoon.action.START"; + public static final String ACTION_STOP = "com.tomcooks.reccoon.action.STOP"; + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent != null && ACTION_STOP.equals(intent.getAction())) { + stopForeground(true); + stopSelf(); + return START_NOT_STICKY; + } + createChannel(); + Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.notification_recording)) + .setSmallIcon(R.drawable.ic_rec_dot) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE); + } else { + startForeground(NOTIFICATION_ID, notification); + } + return START_NOT_STICKY; + } + + private void createChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationManager manager = getSystemService(NotificationManager.class); + if (manager != null && manager.getNotificationChannel(CHANNEL_ID) == null) { + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + getString(R.string.notification_channel), + NotificationManager.IMPORTANCE_LOW); + channel.setShowBadge(false); + manager.createNotificationChannel(channel); + } + } + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } +} diff --git a/app/src/main/java/com/tomcooks/reccoon/RecordingStore.java b/app/src/main/java/com/tomcooks/reccoon/RecordingStore.java new file mode 100644 index 0000000..b7a1c84 --- /dev/null +++ b/app/src/main/java/com/tomcooks/reccoon/RecordingStore.java @@ -0,0 +1,128 @@ +package com.tomcooks.reccoon; + +import android.content.Context; +import android.content.ContentUris; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.MediaStore; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Lists the WAV files saved by RECCoon in the public Downloads folder. */ +public final class RecordingStore { + + public static final class Recording { + public final String name; + public final Uri uri; + public final long sizeBytes; + public final long modifiedSeconds; + public long durationMs; + + Recording(String name, Uri uri, long sizeBytes, long modifiedSeconds) { + this.name = name; + this.uri = uri; + this.sizeBytes = sizeBytes; + this.modifiedSeconds = modifiedSeconds; + } + } + + private static final String PREFIX = "reccoon-"; + private static final String[] SUFFIXES = {".wav", ".m4a"}; + + private final Context context; + + public RecordingStore(Context context) { + this.context = context.getApplicationContext(); + } + + private static boolean hasKnownSuffix(String name) { + for (String suffix : SUFFIXES) { + if (name.endsWith(suffix)) { + return true; + } + } + return false; + } + + public List list() { + List recordings = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + ? listFromMediaStore() + : listFromDirectory(); + for (Recording recording : recordings) { + recording.durationMs = TranscriptionEngine.durationMillis(context, recording.uri); + } + Collections.sort(recordings, (a, b) -> Long.compare(b.modifiedSeconds, a.modifiedSeconds)); + return recordings; + } + + private List listFromMediaStore() { + List result = new ArrayList<>(); + String[] projection = { + MediaStore.MediaColumns._ID, + MediaStore.MediaColumns.DISPLAY_NAME, + MediaStore.MediaColumns.SIZE, + MediaStore.MediaColumns.DATE_MODIFIED, + }; + StringBuilder selection = new StringBuilder(MediaStore.MediaColumns.DISPLAY_NAME) + .append(" LIKE ? AND ("); + for (int i = 0; i < SUFFIXES.length; i++) { + if (i > 0) { + selection.append(" OR "); + } + selection.append(MediaStore.MediaColumns.DISPLAY_NAME).append(" LIKE ?"); + } + selection.append(')'); + String[] args = new String[SUFFIXES.length + 1]; + args[0] = PREFIX + "%"; + for (int i = 0; i < SUFFIXES.length; i++) { + args[i + 1] = "%" + SUFFIXES[i]; + } + try (Cursor cursor = context.getContentResolver().query( + MediaStore.Downloads.EXTERNAL_CONTENT_URI, + projection, selection.toString(), args, null)) { + if (cursor == null) { + return result; + } + int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID); + int nameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME); + int sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE); + int dateColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED); + while (cursor.moveToNext()) { + long id = cursor.getLong(idColumn); + Uri uri = ContentUris.withAppendedId( + MediaStore.Downloads.EXTERNAL_CONTENT_URI, id); + result.add(new Recording( + cursor.getString(nameColumn), + uri, + cursor.getLong(sizeColumn), + cursor.getLong(dateColumn))); + } + } catch (Exception e) { + // Ignore and return what we have. + } + return result; + } + + @SuppressWarnings("deprecation") + private List listFromDirectory() { + List result = new ArrayList<>(); + File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + File[] files = dir.listFiles((d, name) -> name.startsWith(PREFIX) && hasKnownSuffix(name)); + if (files == null) { + return result; + } + for (File file : files) { + result.add(new Recording( + file.getName(), + Uri.fromFile(file), + file.length(), + file.lastModified() / 1000L)); + } + return result; + } +} diff --git a/app/src/main/java/com/tomcooks/reccoon/StreamingResampler.java b/app/src/main/java/com/tomcooks/reccoon/StreamingResampler.java new file mode 100644 index 0000000..558f5ec --- /dev/null +++ b/app/src/main/java/com/tomcooks/reccoon/StreamingResampler.java @@ -0,0 +1,57 @@ +package com.tomcooks.reccoon; + +import java.util.Arrays; + +/** + * Linear-interpolation streaming resampler. + * + *

Keeps the fractional read position between calls so chunks can be fed + * continuously without glitches at the boundaries. + */ +final class StreamingResampler { + + private static final float[] EMPTY = new float[0]; + + private final double ratio; + private float[] buffer = new float[0]; + private double position; + + StreamingResampler(int inputRate, int outputRate) { + if (inputRate <= 0 || outputRate <= 0) { + throw new IllegalArgumentException("Sample rates must be positive"); + } + this.ratio = inputRate / (double) outputRate; + } + + float[] process(float[] input) { + if (input == null || input.length == 0) { + return EMPTY; + } + float[] combined = Arrays.copyOf(buffer, buffer.length + input.length); + System.arraycopy(input, 0, combined, buffer.length, input.length); + buffer = combined; + + int capacity = (int) Math.max(1, (buffer.length - position) / ratio + 2); + float[] output = new float[capacity]; + int count = 0; + while (position + 1 < buffer.length && count < output.length) { + int index = (int) position; + float fraction = (float) (position - index); + output[count++] = buffer[index] * (1f - fraction) + buffer[index + 1] * fraction; + position += ratio; + } + + // Drop the samples we have fully passed. `position` can overshoot the + // buffer end by up to one step, so clamp before trimming: otherwise + // Arrays.copyOfRange throws "from > to". + int drop = (int) position; + if (drop > buffer.length) { + drop = buffer.length; + } + if (drop > 0) { + buffer = Arrays.copyOfRange(buffer, drop, buffer.length); + position -= drop; + } + return count == output.length ? output : Arrays.copyOf(output, count); + } +} diff --git a/app/src/main/java/com/tomcooks/reccoon/TranscriptStore.java b/app/src/main/java/com/tomcooks/reccoon/TranscriptStore.java new file mode 100644 index 0000000..69d9439 --- /dev/null +++ b/app/src/main/java/com/tomcooks/reccoon/TranscriptStore.java @@ -0,0 +1,129 @@ +package com.tomcooks.reccoon; + +import android.content.Context; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +/** Stores and loads transcript sidecar files in app-private storage. */ +public final class TranscriptStore { + + private final File dir; + + public TranscriptStore(Context context) { + dir = new File(context.getApplicationContext().getFilesDir(), "transcripts"); + } + + public boolean has(String recordingName) { + return fileFor(recordingName).isFile(); + } + + public void save(String recordingName, TranscriptionEngine.Result result) throws IOException { + JSONObject root = new JSONObject(); + JSONArray segments = new JSONArray(); + try { + root.put("language", result.language == null ? "" : result.language); + for (TranscriptionEngine.Segment segment : result.segments) { + JSONObject item = new JSONObject(); + item.put("start", segment.startSeconds); + item.put("end", segment.endSeconds); + item.put("text", segment.text); + JSONArray words = new JSONArray(); + for (TranscriptionEngine.Word word : segment.words) { + JSONObject w = new JSONObject(); + w.put("text", word.text); + w.put("start", word.startSeconds); + w.put("end", word.endSeconds); + words.put(w); + } + item.put("words", words); + segments.put(item); + } + root.put("segments", segments); + } catch (JSONException e) { + throw new IOException(e); + } + + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("Cannot create transcript directory"); + } + String json; + try { + json = root.toString(2); + } catch (JSONException e) { + throw new IOException(e); + } + try (FileOutputStream out = new FileOutputStream(fileFor(recordingName))) { + out.write(json.getBytes(StandardCharsets.UTF_8)); + } + } + + public TranscriptionEngine.Result load(String recordingName) { + File file = fileFor(recordingName); + if (!file.isFile()) { + return null; + } + try (InputStream in = new java.io.FileInputStream(file)) { + byte[] buffer = new byte[(int) file.length()]; + int read = 0; + while (read < buffer.length) { + int n = in.read(buffer, read, buffer.length - read); + if (n < 0) { + break; + } + read += n; + } + JSONObject root = new JSONObject(new String(buffer, 0, read, StandardCharsets.UTF_8)); + TranscriptionEngine.Result result = new TranscriptionEngine.Result(); + result.language = root.optString("language", ""); + JSONArray segments = root.optJSONArray("segments"); + if (segments != null) { + for (int i = 0; i < segments.length(); i++) { + JSONObject item = segments.getJSONObject(i); + TranscriptionEngine.Segment segment = new TranscriptionEngine.Segment( + (float) item.optDouble("start", 0), + (float) item.optDouble("end", 0), + item.optString("text", "")); + JSONArray words = item.optJSONArray("words"); + if (words != null) { + for (int j = 0; j < words.length(); j++) { + JSONObject w = words.getJSONObject(j); + segment.words.add(new TranscriptionEngine.Word( + w.optString("text", ""), + (float) w.optDouble("start", 0), + (float) w.optDouble("end", 0))); + } + } + result.segments.add(segment); + } + } + return result; + } catch (Exception e) { + return null; + } + } + + public void delete(String recordingName) { + //noinspection ResultOfMethodCallIgnored + fileFor(recordingName).delete(); + } + + public void rename(String oldName, String newName) { + File from = fileFor(oldName); + if (from.isFile()) { + //noinspection ResultOfMethodCallIgnored + from.renameTo(fileFor(newName)); + } + } + + private File fileFor(String recordingName) { + return new File(dir, recordingName + ".json"); + } +} diff --git a/app/src/main/java/com/tomcooks/reccoon/TranscriptionEngine.java b/app/src/main/java/com/tomcooks/reccoon/TranscriptionEngine.java new file mode 100644 index 0000000..5483cbf --- /dev/null +++ b/app/src/main/java/com/tomcooks/reccoon/TranscriptionEngine.java @@ -0,0 +1,413 @@ +package com.tomcooks.reccoon; + +import android.content.Context; +import android.media.MediaMetadataRetriever; +import android.net.Uri; + +import com.k2fsa.sherpa.onnx.FeatureConfig; +import com.k2fsa.sherpa.onnx.OfflineModelConfig; +import com.k2fsa.sherpa.onnx.OfflineRecognizer; +import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig; +import com.k2fsa.sherpa.onnx.OfflineRecognizerResult; +import com.k2fsa.sherpa.onnx.OfflineStream; +import com.k2fsa.sherpa.onnx.OfflineTransducerModelConfig; +import com.k2fsa.sherpa.onnx.SileroVadModelConfig; +import com.k2fsa.sherpa.onnx.SpeechSegment; +import com.k2fsa.sherpa.onnx.Vad; +import com.k2fsa.sherpa.onnx.VadModelConfig; + +import java.io.BufferedInputStream; +import java.io.DataInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * On-device transcription using sherpa-onnx with the NVIDIA Parakeet TDT 0.6B + * v3 transducer (multilingual, auto language handling). + * + *

Long recordings are streamed, resampled to 16 kHz and segmented with + * Silero VAD, then each detected speech segment is decoded. This keeps memory + * bounded regardless of the recording length. + */ +public final class TranscriptionEngine { + + private static final int TARGET_RATE = 16_000; + + public static final class Word { + public final String text; + public final float startSeconds; + public final float endSeconds; + + public Word(String text, float startSeconds, float endSeconds) { + this.text = text; + this.startSeconds = startSeconds; + this.endSeconds = endSeconds; + } + } + + public static final class Segment { + public final float startSeconds; + public final float endSeconds; + public final String text; + public final List words = new ArrayList<>(); + + public Segment(float startSeconds, float endSeconds, String text) { + this.startSeconds = startSeconds; + this.endSeconds = endSeconds; + this.text = text; + } + } + + public static final class Result { + public final List segments = new ArrayList<>(); + public String language = ""; + + /** Plain transcript with one segment per paragraph. */ + public String asText() { + StringBuilder sb = new StringBuilder(); + for (Segment segment : segments) { + if (sb.length() > 0) { + sb.append('\n'); + } + sb.append(segment.text); + } + return sb.toString(); + } + } + + public interface Callback { + /** @param percent 0..100 */ + void onProgress(int percent, String message); + + boolean isCancelled(); + } + + private final Context context; + private final ModelRepository models; + + public TranscriptionEngine(Context context) { + this.context = context.getApplicationContext(); + this.models = new ModelRepository(this.context); + } + + public Result transcribe(Uri wavUri, + ModelRepository.Model model, + Callback callback) throws IOException { + if (!models.isReady(model)) { + throw new IOException("Model is not downloaded"); + } + + OfflineRecognizer recognizer = buildRecognizer(model); + Vad vad = buildVad(); + Result result = new Result(); + result.language = ""; + + // PcmAudioSource reads uncompressed WAV directly and decodes compressed + // recordings (AAC/M4A, ...) through MediaCodec, so compact mode keeps + // working here. + try (PcmAudioSource source = PcmAudioSource.open(context, wavUri)) { + int channelCount = source.channels; + StreamingResampler resampler = + new StreamingResampler(source.sampleRate, TARGET_RATE); + long totalPcm16k = estimatePcm16kSamples(source); + long processed = 0; + + int frameBytes = channelCount * 2; + byte[] readBuffer = new byte[frameBytes * 8192]; + + while (true) { + if (callback != null && callback.isCancelled()) { + return null; + } + int read = source.read(readBuffer, 0, readBuffer.length); + if (read <= 0) { + break; + } + + int frames = read / frameBytes; + float[] mono = new float[frames]; + for (int i = 0; i < frames; i++) { + int offset = i * frameBytes; + if (channelCount == 1) { + mono[i] = pcm16(readBuffer, offset) / 32768f; + } else { + int sum = 0; + for (int c = 0; c < channelCount; c++) { + sum += pcm16(readBuffer, offset + c * 2); + } + mono[i] = sum / (float) channelCount / 32768f; + } + } + + float[] resampled = resampler.process(mono); + processed += resampled.length; + if (resampled.length > 0) { + vad.acceptWaveform(resampled); + drainSegments(vad, recognizer, result); + } + if (callback != null && totalPcm16k > 0) { + int percent = (int) Math.min(99, processed * 100 / totalPcm16k); + callback.onProgress(percent, "Transcribing…"); + } + } + + vad.flush(); + drainSegments(vad, recognizer, result); + } finally { + vad.release(); + recognizer.release(); + } + + if (callback != null) { + callback.onProgress(100, "Done"); + } + return result; + } + + private void drainSegments(Vad vad, OfflineRecognizer recognizer, Result result) { + while (!vad.empty()) { + SpeechSegment speech = vad.front(); + vad.pop(); + float[] samples = speech.getSamples(); + if (samples == null || samples.length < TARGET_RATE / 10) { + continue; // ignore blips shorter than 100 ms + } + OfflineStream stream = recognizer.createStream(); + try { + stream.acceptWaveform(samples, TARGET_RATE); + recognizer.decode(stream); + OfflineRecognizerResult decoded = recognizer.getResult(stream); + String text = decoded.getText().trim(); + if (!text.isEmpty()) { + float start = speech.getStart() / (float) TARGET_RATE; + float end = start + samples.length / (float) TARGET_RATE; + Segment segment = new Segment(start, end, text); + buildWords(segment, decoded.getTokens(), decoded.getTimestamps(), + decoded.getDurations()); + result.segments.add(segment); + } + } finally { + stream.release(); + } + } + } + + /** + * Fills in word timings. Uses token timestamps when the model provides + * them, otherwise distributes the words across the segment by length. + */ + private static void buildWords(Segment segment, String[] tokens, + float[] timestamps, float[] durations) { + if (tokens == null || tokens.length == 0) { + return; + } + boolean haveTimestamps = timestamps != null && timestamps.length >= tokens.length; + if (haveTimestamps) { + for (int i = 0; i < tokens.length; i++) { + if (tokens[i] == null || tokens[i].trim().isEmpty()) { + continue; + } + float start = segment.startSeconds + timestamps[i]; + float end = durations != null && i < durations.length && durations[i] > 0f + ? start + durations[i] : start + 0.2f; + segment.words.add(new Word(tokens[i], start, end)); + } + return; + } + int total = 0; + for (String token : tokens) { + if (token != null && !token.trim().isEmpty()) { + total += token.trim().length(); + } + } + if (total <= 0) { + return; + } + float span = Math.max(0.001f, segment.endSeconds - segment.startSeconds); + int consumed = 0; + for (String token : tokens) { + if (token == null || token.trim().isEmpty()) { + continue; + } + int weight = token.trim().length(); + float start = segment.startSeconds + span * consumed / total; + consumed += weight; + float end = segment.startSeconds + span * consumed / total; + segment.words.add(new Word(token, start, end)); + } + } + + private OfflineRecognizer buildRecognizer(ModelRepository.Model model) { + FeatureConfig feat = new FeatureConfig(); + feat.setSampleRate(TARGET_RATE); + feat.setFeatureDim(80); + feat.setDither(0f); + + OfflineTransducerModelConfig transducer = new OfflineTransducerModelConfig(); + transducer.setEncoder(models.encoder(model).getAbsolutePath()); + transducer.setDecoder(models.decoder(model).getAbsolutePath()); + transducer.setJoiner(models.joiner(model).getAbsolutePath()); + + OfflineModelConfig modelConfig = new OfflineModelConfig(); + modelConfig.setTransducer(transducer); + modelConfig.setModelType("nemo_transducer"); + modelConfig.setTokens(models.tokens(model).getAbsolutePath()); + modelConfig.setNumThreads(Math.max(2, Runtime.getRuntime().availableProcessors() / 2)); + modelConfig.setDebug(false); + modelConfig.setProvider("cpu"); + + OfflineRecognizerConfig config = new OfflineRecognizerConfig(); + config.setFeatConfig(feat); + config.setModelConfig(modelConfig); + config.setDecodingMethod("greedy_search"); + + // Passing a null AssetManager makes sherpa-onnx load the model from + // the absolute file paths configured above. + return new OfflineRecognizer(null, config); + } + + private Vad buildVad() { + SileroVadModelConfig silero = new SileroVadModelConfig(); + silero.setModel(models.vadModel().getAbsolutePath()); + silero.setThreshold(0.5f); + silero.setMinSilenceDuration(0.5f); + silero.setMinSpeechDuration(0.25f); + silero.setWindowSize(512); + silero.setMaxSpeechDuration(20.0f); + + VadModelConfig config = new VadModelConfig(); + config.setSileroVadModelConfig(silero); + config.setSampleRate(TARGET_RATE); + config.setNumThreads(1); + config.setProvider("cpu"); + config.setDebug(false); + + return new Vad(null, config); + } + + // --- WAV handling ----------------------------------------------------- + + public static final class WavInfo { + public int audioFormat; + public int channels; + public int sampleRate; + public int bitsPerSample; + public long dataSize; + + public long durationMillis() { + int byteRate = sampleRate * channels * (bitsPerSample / 8); + if (byteRate <= 0 || dataSize <= 0) { + return 0; + } + return dataSize * 1000L / byteRate; + } + } + + public static WavInfo readWavHeader(InputStream stream) throws IOException { + DataInputStream in = stream instanceof DataInputStream + ? (DataInputStream) stream + : new DataInputStream(stream); + + byte[] header = new byte[12]; + in.readFully(header); + if (header[0] != 'R' || header[1] != 'I' || header[2] != 'F' || header[3] != 'F' + || header[8] != 'W' || header[9] != 'A' || header[10] != 'V' || header[11] != 'E') { + throw new IOException("Not a RIFF/WAVE file"); + } + + WavInfo info = new WavInfo(); + while (true) { + byte[] chunkHeader = new byte[8]; + in.readFully(chunkHeader); + String id = new String(chunkHeader, 0, 4, StandardCharsets.US_ASCII); + long size = le32(chunkHeader, 4) & 0xffffffffL; + + if ("fmt ".equals(id)) { + if (size < 16 || size > 1024) { + throw new IOException("Unsupported fmt chunk"); + } + byte[] fmt = new byte[(int) size]; + in.readFully(fmt); + info.audioFormat = (int) le16(fmt, 0); + info.channels = (int) le16(fmt, 2); + info.sampleRate = (int) le32(fmt, 4); + info.bitsPerSample = (int) le16(fmt, 14); + if ((size & 1) == 1) { + in.readFully(new byte[1]); + } + } else if ("data".equals(id)) { + info.dataSize = size; + return info; + } else { + skipFully(in, size + (size & 1)); + } + } + } + + public static long durationMillis(Context context, Uri uri) { + try (InputStream in = context.getContentResolver().openInputStream(uri)) { + if (in != null) { + WavInfo info = readWavHeader(new BufferedInputStream(in)); + return info.durationMillis(); + } + } catch (Exception ignored) { + // Not a WAV file; try the platform decoder below. + } + MediaMetadataRetriever retriever = new MediaMetadataRetriever(); + try { + retriever.setDataSource(context, uri); + String value = retriever.extractMetadata( + MediaMetadataRetriever.METADATA_KEY_DURATION); + return value == null ? 0 : Long.parseLong(value); + } catch (Exception e) { + return 0; + } finally { + try { + retriever.release(); + } catch (Exception ignored) { + // Ignore. + } + } + } + + private static long estimatePcm16kSamples(PcmAudioSource source) { + if (source.totalBytes <= 0 || source.sampleRate <= 0 || source.channels <= 0) { + return 0; + } + long frames = source.totalBytes / (source.channels * 2L); + return frames * TARGET_RATE / source.sampleRate; + } + + private static int pcm16(byte[] data, int offset) { + return (short) ((data[offset] & 0xff) | (data[offset + 1] << 8)); + } + + private static long le32(byte[] data, int offset) { + return (data[offset] & 0xffL) + | ((data[offset + 1] & 0xffL) << 8) + | ((data[offset + 2] & 0xffL) << 16) + | ((data[offset + 3] & 0xffL) << 24); + } + + private static long le16(byte[] data, int offset) { + return (data[offset] & 0xffL) | ((data[offset + 1] & 0xffL) << 8); + } + + private static void skipFully(DataInputStream in, long count) throws IOException { + long remaining = count; + while (remaining > 0) { + long skipped = in.skip(remaining); + if (skipped <= 0) { + if (in.read() < 0) { + throw new IOException("Unexpected end of WAV file"); + } + skipped = 1; + } + remaining -= skipped; + } + } +} diff --git a/app/src/main/java/com/tomcooks/reccoon/WavMarkers.java b/app/src/main/java/com/tomcooks/reccoon/WavMarkers.java new file mode 100644 index 0000000..7ef8425 --- /dev/null +++ b/app/src/main/java/com/tomcooks/reccoon/WavMarkers.java @@ -0,0 +1,121 @@ +package com.tomcooks.reccoon; + +import android.content.Context; +import android.net.Uri; + +import java.io.BufferedInputStream; +import java.io.DataInputStream; +import java.io.EOFException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Reads {@code cue } + {@code LIST/adtl} marker chunks out of a WAV file. */ +public final class WavMarkers { + + private WavMarkers() { + } + + public static List read(Context context, Uri uri) { + List result = new ArrayList<>(); + try (InputStream raw = context.getContentResolver().openInputStream(uri)) { + if (raw == null) { + return result; + } + DataInputStream in = new DataInputStream(new BufferedInputStream(raw, 64 * 1024)); + byte[] header = new byte[12]; + in.readFully(header); + if (header[0] != 'R' || header[1] != 'I' || header[2] != 'F' || header[3] != 'F' + || header[8] != 'W' || header[9] != 'A' || header[10] != 'V' + || header[11] != 'E') { + return result; + } + + Map offsets = new LinkedHashMap<>(); + Map labels = new LinkedHashMap<>(); + + while (true) { + byte[] chunkHeader = new byte[8]; + try { + in.readFully(chunkHeader); + } catch (EOFException eof) { + break; + } + String id = new String(chunkHeader, 0, 4, StandardCharsets.US_ASCII); + long size = le32(chunkHeader, 4) & 0xffffffffL; + + if ("cue ".equals(id)) { + byte[] body = new byte[(int) size]; + in.readFully(body); + int count = (int) le32(body, 0); + for (int i = 0; i < count; i++) { + int offset = 4 + i * 24; + int name = (int) le32(body, offset); + long sample = le32(body, offset + 20) & 0xffffffffL; + offsets.put(name, sample); + } + } else if ("LIST".equals(id)) { + byte[] body = new byte[(int) size]; + in.readFully(body); + if (body.length >= 4 + && "adtl".equals(new String(body, 0, 4, StandardCharsets.US_ASCII))) { + int p = 4; + while (p + 8 <= body.length) { + String subId = new String(body, p, 4, StandardCharsets.US_ASCII); + long subSize = le32(body, p + 4) & 0xffffffffL; + if ("labl".equals(subId) && p + 8 + subSize <= body.length) { + int name = (int) le32(body, p + 8); + int textLength = (int) subSize - 4; + if (textLength >= 0) { + String text = new String(body, p + 12, textLength, + StandardCharsets.UTF_8); + int zero = text.indexOf('\0'); + if (zero >= 0) { + text = text.substring(0, zero); + } + labels.put(name, text); + } + } + p += 8 + subSize + (subSize & 1); + } + } + } else { + skipFully(in, size + (size & 1)); + } + } + + int index = 1; + for (Map.Entry entry : offsets.entrySet()) { + String label = labels.get(entry.getKey()); + result.add(new Marker(index++, entry.getValue(), label == null ? "" : label)); + } + } catch (Exception e) { + // Markers are best effort. + } + return result; + } + + private static long le32(byte[] data, int offset) { + return (data[offset] & 0xffL) + | ((data[offset + 1] & 0xffL) << 8) + | ((data[offset + 2] & 0xffL) << 16) + | ((data[offset + 3] & 0xffL) << 24); + } + + private static void skipFully(DataInputStream in, long count) throws Exception { + long remaining = count; + while (remaining > 0) { + long skipped = in.skip(remaining); + if (skipped <= 0) { + if (in.read() < 0) { + throw new EOFException(); + } + skipped = 1; + } + remaining -= skipped; + } + } +} diff --git a/app/src/main/java/com/tomcooks/reccoon/WavRecorder.java b/app/src/main/java/com/tomcooks/reccoon/WavRecorder.java new file mode 100644 index 0000000..0171a74 --- /dev/null +++ b/app/src/main/java/com/tomcooks/reccoon/WavRecorder.java @@ -0,0 +1,542 @@ +package com.tomcooks.reccoon; + +import android.content.Context; +import android.media.AudioDeviceInfo; +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioRecord; +import android.media.MediaRecorder; +import android.os.SystemClock; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Records uncompressed 16-bit PCM audio at 44.1 kHz and writes a standard + * RIFF/WAVE file (PCM, format code 1). + * + *

Stereo is used when the device supports it (so the left/right level + * meters are meaningful); it falls back to mono otherwise. Peak levels and a + * small waveform envelope for both channels are reported through + * {@link LevelListener}. + * + *

Markers added while recording are embedded in the file as standard WAV + * {@code cue } + {@code LIST/adtl} chunks (readable by Audacity, Reaper, ...). + */ +public class WavRecorder { + + public static final int SAMPLE_RATE = 44100; + public static final int BITS_PER_SAMPLE = 16; + + private static final int HEADER_SIZE = 44; + private static final long LEVEL_INTERVAL_MS = 40; + private static final int WAVEFORM_BUCKETS = 16; + + public interface LevelListener { + /** + * @param leftPeak 0..1 peak of the left channel in this window + * @param rightPeak 0..1 peak of the right channel (same as left when mono) + * @param clipped true if any sample reached full scale in this window + * @param waveform min/max pairs (length {@code 2 * WAVEFORM_BUCKETS}) of + * the left/mono channel for a scrolling waveform + */ + void onLevels(float leftPeak, float rightPeak, boolean clipped, float[] waveform); + } + + private final Context context; + private final AudioManager audioManager; + private final List markers = new ArrayList<>(); + + private LevelListener levelListener; + private AudioRecord audioRecord; + private Thread writerThread; + private volatile boolean recording; + private File tempFile; + private volatile long pcmBytes; + private int channels = 2; + private boolean forceMono; + private int preferredDeviceId = -1; + private boolean bluetoothSco; + private boolean scoActive; + private boolean swapChannels; + private boolean monitoring; + private int monitorDeviceId = -1; + private final LiveMonitor monitor; + + public WavRecorder(Context context) { + this.context = context.getApplicationContext(); + this.audioManager = (AudioManager) this.context.getSystemService(Context.AUDIO_SERVICE); + this.monitor = new LiveMonitor(this.context); + } + + public void setLevelListener(LevelListener listener) { + this.levelListener = listener; + } + + /** When true, always record a single channel (half the file size). */ + public void setForceMono(boolean forceMono) { + this.forceMono = forceMono; + } + + /** + * Forces a specific input device, or -1 to let Android pick. Mirrors the + * BT-Mic-Force behaviour: the id comes from + * {@link AudioManager#getDevices(int)}. + */ + public void setPreferredDeviceId(int deviceId) { + this.preferredDeviceId = deviceId; + } + + /** + * When true, route audio through Bluetooth SCO (headset microphone) before + * opening the recorder. Call {@link #setPreferredDeviceId(int)} as well to + * pick the exact headset when several are connected. + */ + public void setBluetoothSco(boolean bluetoothSco) { + this.bluetoothSco = bluetoothSco; + } + + /** + * When true, exchange the two stereo channels before writing. Some devices + * report the microphones in the opposite order once the phone is turned to + * landscape; swapping makes both the meters and the saved file agree with + * what the user hears on their left/right. + */ + public void setSwapChannels(boolean swapChannels) { + this.swapChannels = swapChannels; + } + + /** When true, play the captured audio back through {@link LiveMonitor}. */ + public void setMonitoring(boolean monitoring) { + this.monitoring = monitoring; + } + + /** Output device for live monitoring, or -1 for the system default. */ + public void setMonitorDeviceId(int deviceId) { + this.monitorDeviceId = deviceId; + } + + /** True while live monitoring is actually running. */ + public boolean isMonitoring() { + return monitoring && monitor.isRunning(); + } + + /** Effective number of channels used by the last/current recording. */ + public int getChannels() { + return channels; + } + + /** + * Starts capturing audio. Returns the file that will hold the WAV data. + */ + public File start() throws IOException { + if (recording) { + throw new IllegalStateException("A recording is already in progress"); + } + markers.clear(); + + audioRecord = createAudioRecord(); + if (audioRecord == null) { + throw new IOException("No usable microphone / audio configuration"); + } + + tempFile = new File(context.getCacheDir(), "reccoon_recording.wav"); + writePlaceholderHeader(); + + pcmBytes = 0L; + recording = true; + audioRecord.startRecording(); + + if (monitoring) { + monitor.setPreferredDeviceId(monitorDeviceId); + monitor.start(SAMPLE_RATE, channels); + } + + final AudioRecord record = audioRecord; + final int channelCount = channels; + final int frameBytes = channelCount * 2; + final int minBuffer = AudioRecord.getMinBufferSize( + SAMPLE_RATE, channelConfigFor(channelCount), AudioFormat.ENCODING_PCM_16BIT); + final int chunkSize = Math.max(minBuffer, frameBytes * 4096); + + writerThread = new Thread(() -> { + byte[] buffer = new byte[chunkSize]; + long lastLevelTime = 0; + try (FileOutputStream out = new FileOutputStream(tempFile, true)) { + while (recording) { + int read = record.read(buffer, 0, buffer.length); + if (read > 0) { + maybeSwapChannels(buffer, read, channelCount); + out.write(buffer, 0, read); + if (monitoring) { + monitor.write(buffer, read); + } + pcmBytes += read; + + long now = SystemClock.uptimeMillis(); + if (levelListener != null && now - lastLevelTime >= LEVEL_INTERVAL_MS) { + lastLevelTime = now; + reportLevels(buffer, read, channelCount); + } + } + } + out.flush(); + } catch (Exception ignored) { + // Whatever was captured before the failure stays in the file. + } + }, "reccoon-writer"); + writerThread.start(); + + return tempFile; + } + + /** Exchanges the two 16-bit channels in-place when the swap option is on. */ + private void maybeSwapChannels(byte[] buffer, int length, int channelCount) { + if (!swapChannels || channelCount != 2) { + return; + } + int frames = length / 4; + for (int i = 0; i < frames; i++) { + int offset = i * 4; + byte leftLow = buffer[offset]; + byte leftHigh = buffer[offset + 1]; + byte rightLow = buffer[offset + 2]; + byte rightHigh = buffer[offset + 3]; + buffer[offset] = rightLow; + buffer[offset + 1] = rightHigh; + buffer[offset + 2] = leftLow; + buffer[offset + 3] = leftHigh; + } + } + + private void reportLevels(byte[] buffer, int length, int channelCount) { + int frameBytes = channelCount * 2; + int frames = length / frameBytes; + if (frames <= 0) { + return; + } + float leftPeak = 0f; + float rightPeak = 0f; + boolean clipped = false; + for (int i = 0; i < frames; i++) { + int offset = i * frameBytes; + short left = pcm16(buffer, offset); + float l = Math.abs((int) left) / 32768f; + if (l > leftPeak) { + leftPeak = l; + } + if (Math.abs((int) left) >= 32767) { + clipped = true; + } + if (channelCount > 1) { + short right = pcm16(buffer, offset + 2); + float r = Math.abs((int) right) / 32768f; + if (r > rightPeak) { + rightPeak = r; + } + if (Math.abs((int) right) >= 32767) { + clipped = true; + } + } + } + if (channelCount == 1) { + rightPeak = leftPeak; + } + + float[] waveform = buildWaveform(buffer, frames, frameBytes); + LevelListener listener = levelListener; + if (listener != null) { + listener.onLevels(leftPeak, rightPeak, clipped, waveform); + } + } + + private static float[] buildWaveform(byte[] buffer, int frames, int frameBytes) { + float[] waveform = new float[WAVEFORM_BUCKETS * 2]; + int framesPerBucket = Math.max(1, frames / WAVEFORM_BUCKETS); + for (int bucket = 0; bucket < WAVEFORM_BUCKETS; bucket++) { + int start = bucket * framesPerBucket; + int end = bucket == WAVEFORM_BUCKETS - 1 ? frames : start + framesPerBucket; + float min = 0f; + float max = 0f; + for (int i = start; i < end && i < frames; i++) { + float sample = pcm16(buffer, i * frameBytes) / 32768f; + if (sample < min) { + min = sample; + } + if (sample > max) { + max = sample; + } + } + waveform[bucket * 2] = min; + waveform[bucket * 2 + 1] = max; + } + return waveform; + } + + /** Current position in sample frames (usable while recording). */ + public long currentSampleOffset() { + int blockAlign = Math.max(1, channels * 2); + return pcmBytes / blockAlign; + } + + /** Adds a marker at the current position. */ + public void addMarker(String label) { + addMarkerAt(currentSampleOffset(), label); + } + + public void addMarkerAt(long sampleOffset, String label) { + markers.add(new Marker(markers.size() + 1, sampleOffset, label)); + } + + public List getMarkers() { + return Collections.unmodifiableList(new ArrayList<>(markers)); + } + + /** + * Stops capturing and finalizes the WAV header. Returns the finished file. + */ + public File stop() { + recording = false; + + AudioRecord record = audioRecord; + if (record != null) { + try { + if (record.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING) { + record.stop(); + } + } catch (Exception ignored) { + // Ignore and continue tearing down. + } + } + + Thread thread = writerThread; + if (thread != null) { + try { + thread.join(3000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + if (record != null) { + try { + record.release(); + } catch (Exception ignored) { + // Ignore. + } + audioRecord = null; + } + writerThread = null; + monitor.stop(); + stopSco(); + + writeHeader(); + writeMarkers(); + return tempFile; + } + + public boolean isRecording() { + return recording; + } + + private AudioRecord createAudioRecord() { + startScoIfNeeded(); + int source = bluetoothSco + ? MediaRecorder.AudioSource.VOICE_COMMUNICATION + : MediaRecorder.AudioSource.MIC; + int[] candidates = forceMono + ? new int[]{AudioFormat.CHANNEL_IN_MONO} + : new int[]{AudioFormat.CHANNEL_IN_STEREO, AudioFormat.CHANNEL_IN_MONO}; + for (int channelConfig : candidates) { + int minBuffer = AudioRecord.getMinBufferSize( + SAMPLE_RATE, channelConfig, AudioFormat.ENCODING_PCM_16BIT); + if (minBuffer <= 0) { + continue; + } + int count = channelConfig == AudioFormat.CHANNEL_IN_STEREO ? 2 : 1; + int bufferSize = Math.max(minBuffer, SAMPLE_RATE * count); + AudioRecord candidate = new AudioRecord( + source, + SAMPLE_RATE, + channelConfig, + AudioFormat.ENCODING_PCM_16BIT, + bufferSize); + if (candidate.getState() == AudioRecord.STATE_INITIALIZED) { + applyPreferredDevice(candidate); + channels = count; + return candidate; + } + candidate.release(); + } + stopSco(); + return null; + } + + private void startScoIfNeeded() { + if (!bluetoothSco || audioManager == null) { + return; + } + try { + audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); + audioManager.startBluetoothSco(); + audioManager.setBluetoothScoOn(true); + scoActive = true; + } catch (Exception ignored) { + // Fall back to the default route below. + } + } + + private void applyPreferredDevice(AudioRecord record) { + if (preferredDeviceId < 0 || audioManager == null) { + return; + } + try { + for (AudioDeviceInfo device : audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)) { + if (device.getId() == preferredDeviceId) { + record.setPreferredDevice(device); + return; + } + } + } catch (Exception ignored) { + // Keep the default device. + } + } + + private void stopSco() { + if (!scoActive || audioManager == null) { + return; + } + scoActive = false; + try { + audioManager.stopBluetoothSco(); + audioManager.setBluetoothScoOn(false); + audioManager.setMode(AudioManager.MODE_NORMAL); + } catch (Exception ignored) { + // Ignore. + } + } + + private static int channelConfigFor(int channelCount) { + return channelCount > 1 ? AudioFormat.CHANNEL_IN_STEREO : AudioFormat.CHANNEL_IN_MONO; + } + + private static short pcm16(byte[] data, int offset) { + return (short) ((data[offset] & 0xff) | (data[offset + 1] << 8)); + } + + private void writePlaceholderHeader() throws IOException { + try (RandomAccessFile raf = new RandomAccessFile(tempFile, "rw")) { + raf.setLength(0); + raf.write(new byte[HEADER_SIZE]); + } + } + + private void writeHeader() { + try (RandomAccessFile raf = new RandomAccessFile(tempFile, "rw")) { + raf.seek(0); + raf.write(buildHeader(pcmBytes)); + } catch (IOException ignored) { + // The file is still usable except for the size fields. + } + } + + /** Appends {@code cue } and {@code LIST/adtl} chunks for the markers. */ + private void writeMarkers() { + if (markers.isEmpty() || tempFile == null || !tempFile.isFile()) { + return; + } + try (RandomAccessFile raf = new RandomAccessFile(tempFile, "rw")) { + raf.seek(raf.length()); + raf.write(buildCueChunk(markers)); + raf.write(buildAdtlChunk(markers)); + + long newLength = raf.length(); + raf.seek(4); + raf.write(intLE((int) (newLength - 8))); + } catch (IOException ignored) { + // Markers are a bonus; the audio itself stays valid. + } + } + + private static byte[] buildCueChunk(List markers) { + int count = markers.size(); + ByteBuffer buffer = ByteBuffer.allocate(8 + 4 + 24 * count).order(ByteOrder.LITTLE_ENDIAN); + buffer.put(ascii("cue ")); + buffer.putInt(4 + 24 * count); + buffer.putInt(count); + for (Marker marker : markers) { + buffer.putInt(marker.index); // dwName + buffer.putInt(0); // dwPosition + buffer.put(ascii("data")); // fccChunk + buffer.putInt(0); // dwChunkStart + buffer.putInt(0); // dwBlockStart + buffer.putInt((int) marker.sampleOffset); // dwSampleOffset + } + return buffer.array(); + } + + private static byte[] buildAdtlChunk(List markers) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (Marker marker : markers) { + byte[] text = marker.label.getBytes(StandardCharsets.UTF_8); + int size = 4 + text.length + 1; // cue id + text + NUL + int padded = size + (size & 1); + ByteBuffer buffer = ByteBuffer.allocate(8 + padded).order(ByteOrder.LITTLE_ENDIAN); + buffer.put(ascii("labl")); + buffer.putInt(size); + buffer.putInt(marker.index); + buffer.put(text); + buffer.put((byte) 0); + while (buffer.position() < buffer.capacity()) { + buffer.put((byte) 0); + } + out.write(buffer.array(), 0, buffer.array().length); + } + byte[] adtl = out.toByteArray(); + + ByteBuffer list = ByteBuffer.allocate(12 + adtl.length).order(ByteOrder.LITTLE_ENDIAN); + list.put(ascii("LIST")); + list.putInt(4 + adtl.length); + list.put(ascii("adtl")); + list.put(adtl); + return list.array(); + } + + private byte[] buildHeader(long dataSize) { + int byteRate = SAMPLE_RATE * channels * BITS_PER_SAMPLE / 8; + int blockAlign = channels * BITS_PER_SAMPLE / 8; + + ByteBuffer buffer = ByteBuffer.allocate(HEADER_SIZE).order(ByteOrder.LITTLE_ENDIAN); + buffer.put(ascii("RIFF")); + buffer.putInt((int) (36 + dataSize)); + buffer.put(ascii("WAVE")); + buffer.put(ascii("fmt ")); + buffer.putInt(16); // PCM chunk size + buffer.putShort((short) 1); // PCM = 1 + buffer.putShort((short) channels); + buffer.putInt(SAMPLE_RATE); + buffer.putInt(byteRate); + buffer.putShort((short) blockAlign); + buffer.putShort((short) BITS_PER_SAMPLE); + buffer.put(ascii("data")); + buffer.putInt((int) dataSize); + return buffer.array(); + } + + private static byte[] intLE(int value) { + return ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(value).array(); + } + + private static byte[] ascii(String value) { + return value.getBytes(StandardCharsets.US_ASCII); + } +} diff --git a/app/src/main/java/com/tomcooks/reccoon/WaveformView.java b/app/src/main/java/com/tomcooks/reccoon/WaveformView.java new file mode 100644 index 0000000..413a825 --- /dev/null +++ b/app/src/main/java/com/tomcooks/reccoon/WaveformView.java @@ -0,0 +1,94 @@ +package com.tomcooks.reccoon; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.Nullable; + +/** + * A scrolling waveform of the left/mono channel. Columns whose peak reaches + * full scale are drawn in red so clipping is easy to spot. + */ +public class WaveformView extends View { + + private static final int CAPACITY = 600; + + private final float[] mins = new float[CAPACITY]; + private final float[] maxs = new float[CAPACITY]; + private final boolean[] clipped = new boolean[CAPACITY]; + + private final Paint wavePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint clipPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint centerPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint backgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + + private int count; + private int writeIndex; + + public WaveformView(Context context) { + this(context, null); + } + + public WaveformView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + float density = getResources().getDisplayMetrics().density; + wavePaint.setColor(0xFF3DDC84); + wavePaint.setStrokeWidth(Math.max(1f, density)); + clipPaint.setColor(0xFFE53935); + clipPaint.setStrokeWidth(Math.max(1f, density)); + centerPaint.setColor(0x33FFFFFF); + centerPaint.setStrokeWidth(Math.max(1f, density * 0.6f)); + backgroundPaint.setColor(0x14000000); + } + + /** Appends min/max pairs ({@code [min0,max0,min1,max1,...]}) to the ring. */ + public void append(float[] waveform) { + if (waveform == null || waveform.length < 2) { + return; + } + for (int i = 0; i + 1 < waveform.length; i += 2) { + mins[writeIndex] = waveform[i]; + maxs[writeIndex] = waveform[i + 1]; + clipped[writeIndex] = Math.abs(waveform[i]) >= 0.999f + || Math.abs(waveform[i + 1]) >= 0.999f; + writeIndex = (writeIndex + 1) % CAPACITY; + if (count < CAPACITY) { + count++; + } + } + invalidate(); + } + + public void reset() { + count = 0; + writeIndex = 0; + invalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + int width = getWidth(); + int height = getHeight(); + if (width <= 0 || height <= 0) { + return; + } + canvas.drawRect(0, 0, width, height, backgroundPaint); + + float centerY = height / 2f; + canvas.drawLine(0, centerY, width, centerY, centerPaint); + + float amplitude = centerY * 0.92f; + float step = (float) width / CAPACITY; + for (int i = 0; i < count; i++) { + int index = (writeIndex - count + i + CAPACITY) % CAPACITY; + float x = i * step + step / 2f; + float top = centerY - maxs[index] * amplitude; + float bottom = centerY - mins[index] * amplitude; + canvas.drawLine(x, top, x, bottom, clipped[index] ? clipPaint : wavePaint); + } + } +} diff --git a/app/src/main/java/com/wuhei/reccoon/Formats.java b/app/src/main/java/com/wuhei/reccoon/Formats.java deleted file mode 100644 index 37e0909..0000000 --- a/app/src/main/java/com/wuhei/reccoon/Formats.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.wuhei.reccoon; - -import java.util.Locale; - -/** Small formatting helpers shared by the activities. */ -public final class Formats { - - private Formats() { - } - - /** Turns {@code reccoon-2026-09-24-153000-00001.wav} into a readable label. */ - public static String prettyName(String fileName) { - if (fileName == null) { - return ""; - } - String core = fileName; - if (core.startsWith("reccoon-")) { - core = core.substring("reccoon-".length()); - } - if (core.endsWith(".wav")) { - core = core.substring(0, core.length() - 4); - } - String[] parts = core.split("-"); - if (parts.length >= 4) { - String date = parts[0] + "-" + parts[1] + "-" + parts[2]; - String time = parts[3]; - String sequence = parts.length >= 5 ? parts[4] : ""; - if (time.length() == 6) { - time = time.substring(0, 2) + ":" + time.substring(2, 4) + ":" + time.substring(4); - } - return date + " " + time + (sequence.isEmpty() ? "" : " #" + sequence); - } - return core; - } - - public static String duration(long millis) { - long totalSeconds = Math.max(0, millis) / 1000; - long hours = totalSeconds / 3600; - long minutes = (totalSeconds % 3600) / 60; - long seconds = totalSeconds % 60; - if (hours > 0) { - return String.format(Locale.US, "%d:%02d:%02d", hours, minutes, seconds); - } - return String.format(Locale.US, "%d:%02d", minutes, seconds); - } - - public static String size(long bytes) { - double mb = bytes / 1048576.0; - if (mb >= 1) { - return String.format(Locale.US, "%.1f MB", mb); - } - return String.format(Locale.US, "%d KB", bytes / 1024); - } - - public static String humanBytes(long bytes) { - double mb = bytes / 1048576.0; - if (mb >= 1) { - return String.format(Locale.US, "%.0f MB", mb); - } - return String.format(Locale.US, "%.0f KB", bytes / 1024.0); - } - - /** mm:ss or h:mm:ss from milliseconds. */ - public static String clock(int millis) { - return duration(millis); - } -} diff --git a/app/src/main/java/com/wuhei/reccoon/InsetsHelper.java b/app/src/main/java/com/wuhei/reccoon/InsetsHelper.java deleted file mode 100644 index 660fa64..0000000 --- a/app/src/main/java/com/wuhei/reccoon/InsetsHelper.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.wuhei.reccoon; - -import android.view.View; - -import androidx.core.graphics.Insets; -import androidx.core.view.ViewCompat; -import androidx.core.view.WindowInsetsCompat; - -/** - * Adds the system bar insets to a root view's padding so content is not hidden - * behind the status bar / notch or the navigation bar (edge-to-edge safe). - */ -public final class InsetsHelper { - - private InsetsHelper() { - } - - public static void apply(View root) { - if (root == null) { - return; - } - final int left = root.getPaddingLeft(); - final int top = root.getPaddingTop(); - final int right = root.getPaddingRight(); - final int bottom = root.getPaddingBottom(); - ViewCompat.setOnApplyWindowInsetsListener(root, (view, windowInsets) -> { - Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); - view.setPadding(left + insets.left, top + insets.top, - right + insets.right, bottom + insets.bottom); - return windowInsets; - }); - } -} diff --git a/app/src/main/java/com/wuhei/reccoon/LevelMeterView.java b/app/src/main/java/com/wuhei/reccoon/LevelMeterView.java deleted file mode 100644 index d4b13e3..0000000 --- a/app/src/main/java/com/wuhei/reccoon/LevelMeterView.java +++ /dev/null @@ -1,160 +0,0 @@ -package com.wuhei.reccoon; - -import android.content.Context; -import android.graphics.Canvas; -import android.graphics.LinearGradient; -import android.graphics.Paint; -import android.graphics.RectF; -import android.graphics.Shader; -import android.util.AttributeSet; -import android.view.View; - -import androidx.annotation.Nullable; - -/** - * Two-channel (left/right) peak level meter with a decaying peak hold and a - * clip indicator. Levels are supplied as 0..1 linear amplitudes and mapped to - * a dBFS scale. - */ -public class LevelMeterView extends View { - - private static final float MIN_DB = -60f; - private static final long CLIP_HOLD_MS = 900L; - - private final Paint backgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG); - private final Paint barPaint = new Paint(Paint.ANTI_ALIAS_FLAG); - private final Paint peakPaint = new Paint(Paint.ANTI_ALIAS_FLAG); - private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG); - private final Paint clipPaint = new Paint(Paint.ANTI_ALIAS_FLAG); - - private final RectF barRect = new RectF(); - - private Shader gradient; - - private float leftTarget; - private float rightTarget; - private float leftLevel; - private float rightLevel; - private float leftPeak; - private float rightPeak; - private long clipUntil; - - public LevelMeterView(Context context) { - this(context, null); - } - - public LevelMeterView(Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - float density = getResources().getDisplayMetrics().density; - backgroundPaint.setColor(0x22FFFFFF); - peakPaint.setColor(0xFFFFFFFF); - labelPaint.setColor(0xFF93A0B0); - labelPaint.setTextSize(11f * density); - clipPaint.setColor(0xFFE53935); - clipPaint.setTextSize(11f * density); - clipPaint.setFakeBoldText(true); - clipPaint.setTextAlign(Paint.Align.RIGHT); - } - - /** Feed one measurement window. Amplitudes are 0..1. */ - public void setLevels(float leftPeak, float rightPeak, boolean clipped) { - this.leftTarget = clamp(leftPeak); - this.rightTarget = clamp(rightPeak); - if (clipped) { - clipUntil = System.currentTimeMillis() + CLIP_HOLD_MS; - } - invalidate(); - } - - public void reset() { - leftTarget = rightTarget = leftLevel = rightLevel = 0f; - leftPeak = rightPeak = 0f; - clipUntil = 0L; - invalidate(); - } - - @Override - protected void onSizeChanged(int w, int h, int oldw, int oldh) { - super.onSizeChanged(w, h, oldw, oldh); - gradient = new LinearGradient(0, 0, w, 0, - new int[]{0xFF3DDC84, 0xFF9CCC65, 0xFFFFD54F, 0xFFFF5252}, - new float[]{0f, 0.55f, 0.8f, 1f}, - Shader.TileMode.CLAMP); - } - - @Override - protected void onDraw(Canvas canvas) { - leftLevel = approach(leftLevel, leftTarget); - rightLevel = approach(rightLevel, rightTarget); - leftPeak = Math.max(leftTarget, leftPeak * 0.92f); - rightPeak = Math.max(rightTarget, rightPeak * 0.92f); - - float density = getResources().getDisplayMetrics().density; - float gap = 6f * density; - float barHeight = (getHeight() - gap) / 2f; - float labelWidth = labelPaint.measureText("L") + 10f * density; - float rightEdge = getWidth() - clipPaint.measureText("CLIP") - 10f * density; - - drawBar(canvas, 0f, labelWidth, rightEdge - labelWidth, barHeight, - leftLevel, leftPeak, "L"); - drawBar(canvas, barHeight + gap, labelWidth, rightEdge - labelWidth, barHeight, - rightLevel, rightPeak, "R"); - - if (System.currentTimeMillis() < clipUntil) { - float baseline = getHeight() / 2f - (clipPaint.descent() + clipPaint.ascent()) / 2f; - canvas.drawText("CLIP", getWidth(), baseline, clipPaint); - } - - // Keep animating while there is something to decay. - if (leftLevel > 0.002f || rightLevel > 0.002f - || leftPeak > 0.002f || rightPeak > 0.002f) { - postInvalidateOnAnimation(); - } - } - - private void drawBar(Canvas canvas, float top, float left, float width, float height, - float level, float peak, String label) { - float density = getResources().getDisplayMetrics().density; - float labelBaseline = top + height / 2f - (labelPaint.descent() + labelPaint.ascent()) / 2f; - canvas.drawText(label, 0f, labelBaseline, labelPaint); - - barRect.set(left, top, left + width, top + height); - canvas.drawRoundRect(barRect, height / 2f, height / 2f, backgroundPaint); - - float filled = width * toPosition(level); - if (filled > 0f) { - barPaint.setShader(gradient); - barRect.set(left, top, left + filled, top + height); - canvas.drawRoundRect(barRect, height / 2f, height / 2f, barPaint); - barPaint.setShader(null); - } - - float peakX = left + width * toPosition(peak); - if (peakX > left + 1f) { - canvas.drawRect(peakX - 1.5f * density, top, peakX, top + height, peakPaint); - } - } - - /** Maps a linear amplitude to a 0..1 bar position on a dBFS scale. */ - private static float toPosition(float amplitude) { - if (amplitude <= 0f) { - return 0f; - } - double db = 20.0 * Math.log10(amplitude); - if (db < MIN_DB) { - return 0f; - } - return clamp((float) ((db - MIN_DB) / -MIN_DB)); - } - - private static float approach(float current, float target) { - if (target >= current) { - return target; - } - return current + (target - current) * 0.25f; - } - - private static float clamp(float value) { - return Math.max(0f, Math.min(1f, value)); - } -} diff --git a/app/src/main/java/com/wuhei/reccoon/MainActivity.java b/app/src/main/java/com/wuhei/reccoon/MainActivity.java deleted file mode 100644 index d11726f..0000000 --- a/app/src/main/java/com/wuhei/reccoon/MainActivity.java +++ /dev/null @@ -1,522 +0,0 @@ -package com.wuhei.reccoon; - -import android.Manifest; -import android.animation.ObjectAnimator; -import android.app.NotificationManager; -import android.content.ContentResolver; -import android.content.ContentValues; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.media.MediaScannerConnection; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.os.Environment; -import android.os.Handler; -import android.os.Looper; -import android.os.SystemClock; -import android.provider.MediaStore; -import android.provider.Settings; -import android.text.InputType; -import android.view.LayoutInflater; -import android.view.View; -import android.view.WindowManager; -import android.widget.Button; -import android.widget.EditText; -import android.widget.FrameLayout; -import android.widget.ImageButton; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.widget.AppCompatCheckBox; -import androidx.core.content.ContextCompat; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Date; -import java.util.List; -import java.util.Locale; - -public class MainActivity extends AppCompatActivity { - - private static final int REQUEST_PERMISSIONS = 42; - private static final String PREFS = "reccoon"; - private static final String PREF_COUNTER = "counter"; - private static final String PREF_SILENCE = "silence_notifications"; - private static final String PREF_MONO = "mono_recording"; - - private ImageButton recordButton; - private ImageView recIndicator; - private TextView timerText; - private TextView statusText; - private LevelMeterView levelMeter; - private WaveformView waveformView; - private AppCompatCheckBox silenceCheckbox; - private AppCompatCheckBox monoCheckbox; - private Button markButton; - private LinearLayout recordingsContainer; - private TextView emptyText; - private int savedInterruptionFilter = -1; - - private WavRecorder recorder; - private RecordingStore recordingStore; - private TranscriptStore transcriptStore; - private ObjectAnimator blinkAnimator; - private final Handler handler = new Handler(Looper.getMainLooper()); - - private boolean recording; - private boolean busy; - private long startedAtMs; - private Runnable timerRunnable; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); - - recordButton = findViewById(R.id.recordButton); - recIndicator = findViewById(R.id.recIndicator); - timerText = findViewById(R.id.timerText); - statusText = findViewById(R.id.statusText); - levelMeter = findViewById(R.id.levelMeter); - waveformView = findViewById(R.id.waveformView); - silenceCheckbox = findViewById(R.id.silenceCheckbox); - monoCheckbox = findViewById(R.id.monoCheckbox); - markButton = findViewById(R.id.markButton); - recordingsContainer = findViewById(R.id.recordingsContainer); - emptyText = findViewById(R.id.emptyText); - - recordingStore = new RecordingStore(this); - transcriptStore = new TranscriptStore(this); - - recorder = new WavRecorder(this); - recorder.setLevelListener((leftPeak, rightPeak, clipped, waveform) -> - runOnUiThread(() -> { - if (recording) { - levelMeter.setLevels(leftPeak, rightPeak, clipped); - waveformView.append(waveform); - } - })); - - setUpSilence(); - setUpMono(); - markButton.setOnClickListener(v -> addMarker()); - InsetsHelper.apply(findViewById(R.id.root)); - - blinkAnimator = ObjectAnimator.ofFloat(recIndicator, View.ALPHA, 1f, 0.15f); - blinkAnimator.setDuration(600); - blinkAnimator.setRepeatCount(ObjectAnimator.INFINITE); - blinkAnimator.setRepeatMode(ObjectAnimator.REVERSE); - - recordButton.setOnClickListener(v -> onRecordButtonClicked()); - } - - @Override - protected void onResume() { - super.onResume(); - renderRecordings(); - } - - private void renderRecordings() { - recordingsContainer.removeAllViews(); - List recordings = recordingStore.list(); - emptyText.setVisibility(recordings.isEmpty() ? View.VISIBLE : View.GONE); - - LayoutInflater inflater = LayoutInflater.from(this); - for (RecordingStore.Recording recording : recordings) { - View row = inflater.inflate(R.layout.item_recording, recordingsContainer, false); - TextView name = row.findViewById(R.id.nameText); - TextView meta = row.findViewById(R.id.metaText); - TextView badge = row.findViewById(R.id.transcriptBadge); - - name.setText(Formats.prettyName(recording.name)); - meta.setText(Formats.duration(recording.durationMs) - + " · " + Formats.size(recording.sizeBytes)); - badge.setVisibility(transcriptStore.has(recording.name) ? View.VISIBLE : View.GONE); - row.setOnClickListener(v -> openRecording(recording)); - recordingsContainer.addView(row); - } - } - - private void openRecording(RecordingStore.Recording recording) { - Intent intent = new Intent(this, PlayerActivity.class); - intent.putExtra(PlayerActivity.EXTRA_NAME, recording.name); - intent.putExtra(PlayerActivity.EXTRA_URI, recording.uri.toString()); - startActivity(intent); - } - - @Override - protected void onDestroy() { - handler.removeCallbacksAndMessages(null); - if (blinkAnimator != null) { - blinkAnimator.cancel(); - } - applySilence(false); - super.onDestroy(); - } - - private void onRecordButtonClicked() { - if (busy) { - return; - } - if (recording) { - stopRecordingAndSave(); - return; - } - List missing = missingPermissions(); - if (!missing.isEmpty()) { - requestPermissions(missing.toArray(new String[0]), REQUEST_PERMISSIONS); - } else { - startRecording(); - } - } - - private List missingPermissions() { - List missing = new ArrayList<>(); - if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) - != PackageManager.PERMISSION_GRANTED) { - missing.add(Manifest.permission.RECORD_AUDIO); - } - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P - && ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) - != PackageManager.PERMISSION_GRANTED) { - missing.add(Manifest.permission.WRITE_EXTERNAL_STORAGE); - } - return missing; - } - - @Override - public void onRequestPermissionsResult(int requestCode, - @NonNull String[] permissions, - @NonNull int[] grantResults) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - if (requestCode != REQUEST_PERMISSIONS) { - return; - } - if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) - == PackageManager.PERMISSION_GRANTED) { - startRecording(); - } else { - Toast.makeText(this, R.string.err_permission, Toast.LENGTH_LONG).show(); - } - } - - private void setUpSilence() { - SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); - silenceCheckbox.setChecked(prefs.getBoolean(PREF_SILENCE, false)); - silenceCheckbox.setOnCheckedChangeListener((button, checked) -> { - getSharedPreferences(PREFS, MODE_PRIVATE) - .edit().putBoolean(PREF_SILENCE, checked).apply(); - if (checked) { - ensureDndAccess(); - } - }); - } - - private void setUpMono() { - SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); - boolean mono = prefs.getBoolean(PREF_MONO, false); - monoCheckbox.setChecked(mono); - recorder.setForceMono(mono); - monoCheckbox.setOnCheckedChangeListener((button, checked) -> { - getSharedPreferences(PREFS, MODE_PRIVATE) - .edit().putBoolean(PREF_MONO, checked).apply(); - recorder.setForceMono(checked); - }); - } - - private void addMarker() { - if (!recording) { - return; - } - final long offset = recorder.currentSampleOffset(); - final int next = recorder.getMarkers().size() + 1; - - EditText input = new EditText(this); - input.setInputType(InputType.TYPE_CLASS_TEXT); - input.setHint(R.string.marker_hint); - input.setText(getString(R.string.marker_fmt, next)); - input.selectAll(); - - int padding = (int) (20 * getResources().getDisplayMetrics().density); - FrameLayout container = new FrameLayout(this); - container.setPadding(padding, padding / 2, padding, 0); - container.addView(input); - - new AlertDialog.Builder(this) - .setTitle(R.string.marker_title) - .setView(container) - .setPositiveButton(R.string.marker_add, (dialog, which) -> { - String label = input.getText().toString().trim(); - if (label.isEmpty()) { - label = getString(R.string.marker_fmt, next); - } - recorder.addMarkerAt(offset, label); - Toast.makeText(this, getString(R.string.marker_added_fmt, next), - Toast.LENGTH_SHORT).show(); - }) - .setNegativeButton(R.string.cancel, null) - .show(); - } - - private void ensureDndAccess() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - return; - } - NotificationManager manager = - (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - if (manager == null || manager.isNotificationPolicyAccessGranted()) { - return; - } - new AlertDialog.Builder(this) - .setTitle(R.string.dnd_title) - .setMessage(R.string.dnd_message) - .setPositiveButton(R.string.dnd_open_settings, (dialog, which) -> { - try { - startActivity(new Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS)); - } catch (Exception ignored) { - // Ignore. - } - }) - .setNegativeButton(R.string.cancel, - (dialog, which) -> silenceCheckbox.setChecked(false)) - .show(); - } - - /** Enables total-silence Do Not Disturb while recording, then restores it. */ - private void applySilence(boolean enable) { - if (silenceCheckbox == null || !silenceCheckbox.isChecked()) { - return; - } - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - return; - } - NotificationManager manager = - (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - if (manager == null || !manager.isNotificationPolicyAccessGranted()) { - return; - } - try { - if (enable) { - if (savedInterruptionFilter == -1) { - savedInterruptionFilter = manager.getCurrentInterruptionFilter(); - } - manager.setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_NONE); - } else if (savedInterruptionFilter != -1) { - manager.setInterruptionFilter(savedInterruptionFilter); - savedInterruptionFilter = -1; - } - } catch (Exception ignored) { - // Ignore. - } - } - - private void startRecordingService() { - try { - ContextCompat.startForegroundService(this, - new Intent(this, RecordingService.class) - .setAction(RecordingService.ACTION_START)); - } catch (Exception ignored) { - // Recording still works while the app is in the foreground. - } - } - - private void stopRecordingService() { - try { - stopService(new Intent(this, RecordingService.class)); - } catch (Exception ignored) { - // Ignore. - } - } - - private void startRecording() { - recorder.setForceMono(monoCheckbox.isChecked()); - try { - recorder.start(); - } catch (Exception e) { - Toast.makeText(this, R.string.err_record, Toast.LENGTH_LONG).show(); - return; - } - - recording = true; - startedAtMs = SystemClock.elapsedRealtime(); - - startRecordingService(); - levelMeter.reset(); - waveformView.reset(); - markButton.setEnabled(true); - applySilence(true); - recordButton.setBackgroundResource(R.drawable.bg_record_active); - recordButton.setContentDescription(getString(R.string.cd_stop)); - recIndicator.setVisibility(View.VISIBLE); - blinkAnimator.start(); - statusText.setText(R.string.status_recording); - timerText.setText(R.string.timer_zero); - getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - - timerRunnable = new Runnable() { - @Override - public void run() { - long elapsed = SystemClock.elapsedRealtime() - startedAtMs; - timerText.setText(formatDuration(elapsed)); - handler.postDelayed(this, 200); - } - }; - handler.post(timerRunnable); - } - - private void stopRecordingAndSave() { - recording = false; - busy = true; - - stopRecordingService(); - levelMeter.reset(); - waveformView.reset(); - markButton.setEnabled(false); - applySilence(false); - long elapsed = SystemClock.elapsedRealtime() - startedAtMs; - handler.removeCallbacks(timerRunnable); - blinkAnimator.cancel(); - recIndicator.setVisibility(View.INVISIBLE); - getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - timerText.setText(formatDuration(elapsed)); - recordButton.setBackgroundResource(R.drawable.bg_record_idle); - recordButton.setContentDescription(getString(R.string.cd_record)); - recordButton.setEnabled(false); - statusText.setText(R.string.status_saving); - - new Thread(() -> { - File wav; - List markers; - try { - wav = recorder.stop(); - markers = recorder.getMarkers(); - } catch (Exception e) { - wav = null; - markers = Collections.emptyList(); - } - - String savedName = null; - if (wav != null) { - try { - savedName = saveToDownloads(wav); - if (savedName != null && !markers.isEmpty()) { - new MarkerStore(this).save(savedName, markers); - } - } catch (Exception ignored) { - savedName = null; - } - } - - final String result = savedName; - runOnUiThread(() -> { - busy = false; - recordButton.setEnabled(true); - if (result != null) { - statusText.setText(getString(R.string.saved_fmt, result)); - Toast.makeText(this, getString(R.string.saved_fmt, result), Toast.LENGTH_LONG).show(); - } else { - statusText.setText(R.string.err_save); - Toast.makeText(this, R.string.err_save, Toast.LENGTH_LONG).show(); - } - renderRecordings(); - }); - }, "reccoon-save").start(); - } - - private String saveToDownloads(File wav) throws IOException { - String fileName = buildFileName(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - saveViaMediaStore(wav, fileName); - } else { - saveToPublicDownloads(wav, fileName); - } - return fileName; - } - - private void saveViaMediaStore(File wav, String fileName) throws IOException { - ContentResolver resolver = getContentResolver(); - ContentValues values = new ContentValues(); - values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName); - values.put(MediaStore.MediaColumns.MIME_TYPE, "audio/wav"); - values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS); - values.put(MediaStore.MediaColumns.IS_PENDING, 1); - - Uri uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values); - if (uri == null) { - throw new IOException("MediaStore rejected the new file"); - } - - try (OutputStream out = resolver.openOutputStream(uri)) { - if (out == null) { - throw new IOException("MediaStore returned no output stream"); - } - try (InputStream in = new FileInputStream(wav)) { - copy(in, out); - } - } - - ContentValues done = new ContentValues(); - done.put(MediaStore.MediaColumns.IS_PENDING, 0); - resolver.update(uri, done, null, null); - } - - @SuppressWarnings("deprecation") - private void saveToPublicDownloads(File wav, String fileName) throws IOException { - File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); - if (!dir.exists() && !dir.mkdirs()) { - throw new IOException("Could not create the Downloads directory"); - } - File outFile = new File(dir, fileName); - try (InputStream in = new FileInputStream(wav); - OutputStream out = new FileOutputStream(outFile)) { - copy(in, out); - } - MediaScannerConnection.scanFile( - this, - new String[]{outFile.getAbsolutePath()}, - new String[]{"audio/wav"}, - null); - } - - private String buildFileName() { - String stamp = new SimpleDateFormat("yyyy-MM-dd-HHmmss", Locale.US).format(new Date()); - SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); - int next = prefs.getInt(PREF_COUNTER, 0) + 1; - prefs.edit().putInt(PREF_COUNTER, next).apply(); - return String.format(Locale.US, "reccoon-%s-%05d.wav", stamp, next); - } - - private static void copy(InputStream in, OutputStream out) throws IOException { - byte[] buffer = new byte[64 * 1024]; - int read; - while ((read = in.read(buffer)) > 0) { - out.write(buffer, 0, read); - } - } - - private static String formatDuration(long elapsedMs) { - long totalSeconds = elapsedMs / 1000; - long hours = totalSeconds / 3600; - long minutes = (totalSeconds % 3600) / 60; - long seconds = totalSeconds % 60; - if (hours > 0) { - return String.format(Locale.US, "%02d:%02d:%02d", hours, minutes, seconds); - } - return String.format(Locale.US, "%02d:%02d", minutes, seconds); - } -} diff --git a/app/src/main/java/com/wuhei/reccoon/Marker.java b/app/src/main/java/com/wuhei/reccoon/Marker.java deleted file mode 100644 index d4a7ef5..0000000 --- a/app/src/main/java/com/wuhei/reccoon/Marker.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.wuhei.reccoon; - -/** A user-placed marker inside a recording, stored as a WAV cue point. */ -public final class Marker { - - public final int index; - public final long sampleOffset; - public final String label; - - public Marker(int index, long sampleOffset, String label) { - this.index = index; - this.sampleOffset = sampleOffset; - this.label = label == null ? "" : label; - } - - public long timeMillis() { - return sampleOffset * 1000L / WavRecorder.SAMPLE_RATE; - } -} diff --git a/app/src/main/java/com/wuhei/reccoon/MarkerStore.java b/app/src/main/java/com/wuhei/reccoon/MarkerStore.java deleted file mode 100644 index bde7a24..0000000 --- a/app/src/main/java/com/wuhei/reccoon/MarkerStore.java +++ /dev/null @@ -1,110 +0,0 @@ -package com.wuhei.reccoon; - -import android.content.Context; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.List; - -/** - * Stores recording markers as JSON sidecars (the same markers are also - * embedded in the WAV itself as {@code cue } / {@code adtl} chunks). - */ -public final class MarkerStore { - - private final File dir; - - public MarkerStore(Context context) { - dir = new File(context.getApplicationContext().getFilesDir(), "markers"); - } - - public boolean has(String recordingName) { - return fileFor(recordingName).isFile(); - } - - public void save(String recordingName, List markers) throws IOException { - if (markers.isEmpty()) { - return; - } - JSONArray array = new JSONArray(); - try { - for (Marker marker : markers) { - JSONObject item = new JSONObject(); - item.put("index", marker.index); - item.put("offset", marker.sampleOffset); - item.put("label", marker.label); - array.put(item); - } - } catch (JSONException e) { - throw new IOException(e); - } - - if (!dir.exists() && !dir.mkdirs()) { - throw new IOException("Cannot create marker directory"); - } - String json; - try { - json = array.toString(2); - } catch (JSONException e) { - throw new IOException(e); - } - try (FileOutputStream out = new FileOutputStream(fileFor(recordingName))) { - out.write(json.getBytes(StandardCharsets.UTF_8)); - } - } - - public List load(String recordingName) { - List markers = new ArrayList<>(); - File file = fileFor(recordingName); - if (!file.isFile()) { - return markers; - } - try (InputStream in = new java.io.FileInputStream(file)) { - byte[] buffer = new byte[(int) file.length()]; - int read = 0; - while (read < buffer.length) { - int n = in.read(buffer, read, buffer.length - read); - if (n < 0) { - break; - } - read += n; - } - JSONArray array = new JSONArray(new String(buffer, 0, read, StandardCharsets.UTF_8)); - for (int i = 0; i < array.length(); i++) { - JSONObject item = array.getJSONObject(i); - markers.add(new Marker( - item.optInt("index", i + 1), - item.optLong("offset", 0), - item.optString("label", ""))); - } - } catch (Exception e) { - // Return whatever we parsed. - } - return markers; - } - - public void delete(String recordingName) { - //noinspection ResultOfMethodCallIgnored - fileFor(recordingName).delete(); - } - - public void rename(String oldName, String newName) { - File from = fileFor(oldName); - if (from.isFile()) { - //noinspection ResultOfMethodCallIgnored - from.renameTo(fileFor(newName)); - } - } - - private File fileFor(String recordingName) { - return new File(dir, recordingName + ".json"); - } -} diff --git a/app/src/main/java/com/wuhei/reccoon/ModelRepository.java b/app/src/main/java/com/wuhei/reccoon/ModelRepository.java deleted file mode 100644 index cbf25c2..0000000 --- a/app/src/main/java/com/wuhei/reccoon/ModelRepository.java +++ /dev/null @@ -1,206 +0,0 @@ -package com.wuhei.reccoon; - -import android.content.Context; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.net.HttpURLConnection; -import java.net.URL; - -/** - * Downloads and stores the on-device speech recognition models. - * - *

Uses multilingual Whisper (tiny or base, int8) from the sherpa-onnx model - * zoo. The multilingual variants support both English and Italian. A small - * Silero VAD model is used to split long recordings into transcribable chunks. - */ -public final class ModelRepository { - - private static final String HF_BASE = - "https://huggingface.co/csukuangfj/sherpa-onnx-whisper-"; - private static final String VAD_URL = - "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/silero_vad.onnx"; - - public enum WhisperModel { - TINY("tiny", "Tiny", 12_937_772L, 89_855_401L), - BASE("base", "Base", 29_120_534L, 130_672_026L); - - public final String id; - public final String label; - public final long encoderBytes; - public final long decoderBytes; - - WhisperModel(String id, String label, long encoderBytes, long decoderBytes) { - this.id = id; - this.label = label; - this.encoderBytes = encoderBytes; - this.decoderBytes = decoderBytes; - } - - public long totalBytes() { - return encoderBytes + decoderBytes + TOKENS_BYTES + VAD_BYTES; - } - } - - private static final long TOKENS_BYTES = 816_730L; - private static final long VAD_BYTES = 643_854L; - - public interface Listener { - void onProgress(long downloaded, long total, String currentFile); - - default void onComplete() { - } - } - - private final File root; - - public ModelRepository(Context context) { - root = new File(context.getApplicationContext().getFilesDir(), "models"); - } - - public File vadModel() { - return new File(root, "silero_vad.onnx"); - } - - public File encoder(WhisperModel model) { - return new File(modelDir(model), "encoder.int8.onnx"); - } - - public File decoder(WhisperModel model) { - return new File(modelDir(model), "decoder.int8.onnx"); - } - - public File tokens(WhisperModel model) { - return new File(modelDir(model), "tokens.txt"); - } - - private File modelDir(WhisperModel model) { - return new File(root, "whisper-" + model.id); - } - - public boolean isReady(WhisperModel model) { - return isComplete(vadModel(), VAD_BYTES) - && isComplete(encoder(model), model.encoderBytes) - && isComplete(decoder(model), model.decoderBytes) - && isComplete(tokens(model), TOKENS_BYTES); - } - - public boolean isVadReady() { - return isComplete(vadModel(), VAD_BYTES); - } - - private static boolean isComplete(File file, long expected) { - return file.isFile() && (expected <= 0 || file.length() == expected); - } - - /** - * Blocking. Call from a background thread. Progress is reported through - * {@code listener}. Throws on failure. - */ - public void download(WhisperModel model, Listener listener) throws IOException { - long total = model.totalBytes(); - long already = 0; - if (isComplete(vadModel(), VAD_BYTES)) already += VAD_BYTES; - if (isComplete(encoder(model), model.encoderBytes)) already += model.encoderBytes; - if (isComplete(decoder(model), model.decoderBytes)) already += model.decoderBytes; - if (isComplete(tokens(model), TOKENS_BYTES)) already += TOKENS_BYTES; - - Counter counter = new Counter(already, total, listener); - counter.report(""); - - fetch(VAD_URL, vadModel(), VAD_BYTES, counter); - String base = HF_BASE + model.id + "/resolve/main/" + model.id; - fetch(base + "-encoder.int8.onnx", encoder(model), model.encoderBytes, counter); - fetch(base + "-decoder.int8.onnx", decoder(model), model.decoderBytes, counter); - fetch(base + "-tokens.txt", tokens(model), TOKENS_BYTES, counter); - - listener.onComplete(); - } - - private static final class Counter { - private final long already; - private final long total; - private final Listener listener; - private long doneInCurrent; - private String currentName = ""; - - Counter(long already, long total, Listener listener) { - this.already = already; - this.total = total; - this.listener = listener; - } - - void setCurrent(String name, long done) { - this.currentName = name; - this.doneInCurrent = done; - listener.onProgress(Math.min(already + done, total), total, currentName); - } - - void report(String name) { - listener.onProgress(Math.min(already + doneInCurrent, total), total, name); - } - } - - private static void fetch(String url, File target, long expected, Counter counter) - throws IOException { - if (isComplete(target, expected)) { - return; - } - File parent = target.getParentFile(); - if (parent != null && !parent.exists() && !parent.mkdirs()) { - throw new IOException("Cannot create " + parent); - } - File part = new File(parent, target.getName() + ".part"); - - HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); - connection.setInstanceFollowRedirects(true); - connection.setConnectTimeout(30_000); - connection.setReadTimeout(120_000); - connection.setRequestProperty("User-Agent", "RECCoon/1.0 (Android)"); - try { - int code = connection.getResponseCode(); - if (code != HttpURLConnection.HTTP_OK) { - throw new IOException("HTTP " + code + " for " + url); - } - long length = connection.getContentLengthLong(); - long thisTotal = length > 0 ? length : expected; - - try (InputStream in = connection.getInputStream(); - FileOutputStream out = new FileOutputStream(part)) { - byte[] buffer = new byte[256 * 1024]; - long done = 0; - int read; - long lastReport = 0; - while ((read = in.read(buffer)) > 0) { - out.write(buffer, 0, read); - done += read; - long now = System.currentTimeMillis(); - if (now - lastReport > 200) { - lastReport = now; - counter.setCurrent(target.getName(), done); - } - } - out.getFD().sync(); - counter.setCurrent(target.getName(), done); - } - - if (expected > 0 && part.length() != expected) { - //noinspection ResultOfMethodCallIgnored - part.delete(); - throw new IOException( - "Corrupt download for " + target.getName() - + " (expected " + expected + " bytes, got " + part.length() + ")"); - } - if (target.exists() && !target.delete()) { - throw new IOException("Cannot replace " + target); - } - if (!part.renameTo(target)) { - throw new IOException("Cannot move download into place: " + target); - } - } finally { - connection.disconnect(); - } - } -} diff --git a/app/src/main/java/com/wuhei/reccoon/PlayerActivity.java b/app/src/main/java/com/wuhei/reccoon/PlayerActivity.java deleted file mode 100644 index eb5b8ca..0000000 --- a/app/src/main/java/com/wuhei/reccoon/PlayerActivity.java +++ /dev/null @@ -1,628 +0,0 @@ -package com.wuhei.reccoon; - -import android.content.Intent; -import android.graphics.Color; -import android.media.MediaPlayer; -import android.media.PlaybackParams; -import android.net.Uri; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.text.SpannableStringBuilder; -import android.text.Spanned; -import android.text.TextPaint; -import android.text.method.LinkMovementMethod; -import android.text.style.ClickableSpan; -import android.view.View; -import android.widget.ArrayAdapter; -import android.widget.Button; -import android.widget.EditText; -import android.widget.FrameLayout; -import android.widget.LinearLayout; -import android.widget.ProgressBar; -import android.widget.SeekBar; -import android.widget.Spinner; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; - -import java.io.File; -import java.util.List; - -/** - * Plays a saved recording with a scrubber and transcribes it on device with - * sherpa-onnx (multilingual Whisper: English and Italian). - */ -public class PlayerActivity extends AppCompatActivity { - - public static final String EXTRA_NAME = "name"; - public static final String EXTRA_URI = "uri"; - - private static final float[] SPEEDS = {1.0f, 1.5f, 2.0f, 0.5f}; - - private String recordingName; - private Uri recordingUri; - - private MediaPlayer player; - private boolean prepared; - - private TextView titleText; - private TextView timeText; - private TextView statusText; - private SeekBar seekBar; - private Button playButton; - private Button speedButton; - private Button transcribeButton; - private Spinner languageSpinner; - private Spinner modelSpinner; - private ProgressBar progressBar; - private LinearLayout transcriptContainer; - private LinearLayout markersSection; - private LinearLayout markersContainer; - - private final Handler handler = new Handler(Looper.getMainLooper()); - private boolean userSeeking; - private float speed = SPEEDS[0]; - private int speedIndex; - private volatile boolean cancelled; - - private ModelRepository models; - private TranscriptStore transcripts; - private MarkerStore markerStore; - - private final Runnable ticker = new Runnable() { - @Override - public void run() { - updateProgress(); - handler.postDelayed(this, 250); - } - }; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_player); - - Intent intent = getIntent(); - recordingName = intent.getStringExtra(EXTRA_NAME); - String uriString = intent.getStringExtra(EXTRA_URI); - if (recordingName == null || uriString == null) { - finish(); - return; - } - recordingUri = Uri.parse(uriString); - - models = new ModelRepository(this); - transcripts = new TranscriptStore(this); - markerStore = new MarkerStore(this); - - titleText = findViewById(R.id.titleText); - timeText = findViewById(R.id.timeText); - statusText = findViewById(R.id.statusText); - seekBar = findViewById(R.id.seekBar); - playButton = findViewById(R.id.playButton); - speedButton = findViewById(R.id.speedButton); - transcribeButton = findViewById(R.id.transcribeButton); - languageSpinner = findViewById(R.id.languageSpinner); - modelSpinner = findViewById(R.id.modelSpinner); - progressBar = findViewById(R.id.progressBar); - transcriptContainer = findViewById(R.id.transcriptContainer); - markersSection = findViewById(R.id.markersSection); - markersContainer = findViewById(R.id.markersContainer); - - titleText.setText(Formats.prettyName(recordingName)); - - setUpSpinners(); - setUpButtons(); - setUpPlayer(); - - InsetsHelper.apply(findViewById(R.id.root)); - showMarkers(loadMarkers()); - - TranscriptionEngine.Result existing = transcripts.load(recordingName); - if (existing != null) { - showTranscript(existing); - } else { - statusText.setText(R.string.transcribe_hint); - } - - handler.post(ticker); - } - - private void setUpSpinners() { - ArrayAdapter languages = new ArrayAdapter<>(this, - android.R.layout.simple_spinner_item, - new String[]{ - getString(R.string.lang_auto), - getString(R.string.lang_en), - getString(R.string.lang_it), - }); - languages.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - languageSpinner.setAdapter(languages); - - ArrayAdapter modelAdapter = new ArrayAdapter<>(this, - android.R.layout.simple_spinner_item, - new String[]{ - getString(R.string.model_base), - getString(R.string.model_tiny), - }); - modelAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - modelSpinner.setAdapter(modelAdapter); - } - - private void setUpButtons() { - playButton.setOnClickListener(v -> togglePlay()); - speedButton.setOnClickListener(v -> cycleSpeed()); - findViewById(R.id.rewindButton).setOnClickListener(v -> seekBy(-10_000)); - findViewById(R.id.forwardButton).setOnClickListener(v -> seekBy(10_000)); - findViewById(R.id.shareButton).setOnClickListener(v -> shareRecording()); - findViewById(R.id.renameButton).setOnClickListener(v -> showRenameDialog()); - findViewById(R.id.exportButton).setOnClickListener(v -> exportTranscript()); - findViewById(R.id.deleteButton).setOnClickListener(v -> confirmDelete()); - transcribeButton.setOnClickListener(v -> onTranscribeClicked()); - - seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { - @Override - public void onProgressChanged(SeekBar bar, int progress, boolean fromUser) { - if (fromUser) { - timeText.setText(Formats.clock(progress) + " / " + Formats.clock(duration())); - } - } - - @Override - public void onStartTrackingTouch(SeekBar bar) { - userSeeking = true; - } - - @Override - public void onStopTrackingTouch(SeekBar bar) { - userSeeking = false; - if (player != null && prepared) { - player.seekTo(bar.getProgress()); - } - } - }); - } - - private void setUpPlayer() { - player = new MediaPlayer(); - prepared = false; - try { - player.setDataSource(this, recordingUri); - player.setOnPreparedListener(mp -> { - prepared = true; - seekBar.setMax(mp.getDuration()); - updateTimeLabel(); - updatePlayButton(); // no autoplay - }); - player.setOnCompletionListener(mp -> updatePlayButton()); - player.prepareAsync(); - } catch (Exception e) { - Toast.makeText(this, R.string.err_playback, Toast.LENGTH_LONG).show(); - } - } - - private int duration() { - if (player == null || !prepared) { - return 0; - } - try { - return player.getDuration(); - } catch (Exception e) { - return 0; - } - } - - private void togglePlay() { - if (player == null || !prepared) { - return; - } - try { - if (player.isPlaying()) { - player.pause(); - } else { - if (player.getCurrentPosition() >= player.getDuration() - 150) { - player.seekTo(0); - } - player.start(); - applySpeed(); - } - } catch (Exception ignored) { - // Ignore. - } - updatePlayButton(); - } - - private void seekBy(int deltaMs) { - if (player == null || !prepared) { - return; - } - int target = Math.max(0, Math.min(player.getCurrentPosition() + deltaMs, player.getDuration())); - player.seekTo(target); - seekBar.setProgress(target); - updateTimeLabel(); - } - - private void cycleSpeed() { - speedIndex = (speedIndex + 1) % SPEEDS.length; - speed = SPEEDS[speedIndex]; - speedButton.setText(String.format(java.util.Locale.US, "%.1f×", speed)); - applySpeed(); - } - - private void applySpeed() { - if (player == null || !prepared) { - return; - } - try { - PlaybackParams params = player.getPlaybackParams(); - params.setSpeed(speed); - player.setPlaybackParams(params); - } catch (Exception ignored) { - // Some devices reject speed changes while paused. - } - } - - private void updateProgress() { - if (player == null || !prepared) { - return; - } - try { - if (!userSeeking) { - seekBar.setProgress(player.getCurrentPosition()); - } - } catch (Exception ignored) { - // Ignore. - } - updateTimeLabel(); - updatePlayButton(); - } - - private void updateTimeLabel() { - timeText.setText(Formats.clock(player != null && prepared ? player.getCurrentPosition() : 0) - + " / " + Formats.clock(duration())); - } - - private void updatePlayButton() { - boolean playing = false; - if (player != null && prepared) { - try { - playing = player.isPlaying(); - } catch (Exception ignored) { - // Ignore. - } - } - playButton.setText(playing ? R.string.player_pause : R.string.player_play); - } - - // --- Library actions -------------------------------------------------- - - private List loadMarkers() { - List markers = markerStore.load(recordingName); - if (markers.isEmpty()) { - markers = WavMarkers.read(this, recordingUri); - } - return markers; - } - - private void shareRecording() { - try { - Uri shareUri = RecordingActions.shareableUri(this, recordingUri); - Intent intent = new Intent(Intent.ACTION_SEND); - intent.setType("audio/wav"); - intent.putExtra(Intent.EXTRA_STREAM, shareUri); - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - startActivity(Intent.createChooser(intent, getString(R.string.action_share))); - } catch (Exception e) { - Toast.makeText(this, R.string.err_share, Toast.LENGTH_LONG).show(); - } - } - - private void confirmDelete() { - new AlertDialog.Builder(this) - .setTitle(R.string.delete_title) - .setMessage(getString(R.string.delete_message, Formats.prettyName(recordingName))) - .setPositiveButton(R.string.action_delete, (dialog, which) -> { - RecordingActions.delete(this, recordingUri, recordingName); - Toast.makeText(this, R.string.deleted, Toast.LENGTH_SHORT).show(); - finish(); - }) - .setNegativeButton(R.string.cancel, null) - .show(); - } - - private void showRenameDialog() { - EditText input = new EditText(this); - input.setText(recordingName); - input.selectAll(); - - int padding = (int) (20 * getResources().getDisplayMetrics().density); - FrameLayout container = new FrameLayout(this); - container.setPadding(padding, padding / 2, padding, 0); - container.addView(input); - - new AlertDialog.Builder(this) - .setTitle(R.string.rename_title) - .setView(container) - .setPositiveButton(R.string.action_rename, (dialog, which) -> { - String requested = input.getText().toString().trim(); - if (!requested.endsWith(".wav")) { - requested = requested + ".wav"; - } - String renamed = RecordingActions.rename( - this, recordingUri, recordingName, requested); - if (renamed == null) { - Toast.makeText(this, R.string.rename_failed, Toast.LENGTH_LONG).show(); - return; - } - if ("file".equals(recordingUri.getScheme())) { - File parent = new File(recordingUri.getPath()).getParentFile(); - recordingUri = Uri.fromFile(new File(parent, renamed)); - } - recordingName = renamed; - titleText.setText(Formats.prettyName(recordingName)); - showMarkers(loadMarkers()); - Toast.makeText(this, R.string.renamed, Toast.LENGTH_SHORT).show(); - }) - .setNegativeButton(R.string.cancel, null) - .show(); - } - - private void exportTranscript() { - TranscriptionEngine.Result result = transcripts.load(recordingName); - if (result == null || result.segments.isEmpty()) { - Toast.makeText(this, R.string.export_none, Toast.LENGTH_LONG).show(); - return; - } - try { - RecordingActions.exportTranscript(this, recordingName, result); - Toast.makeText(this, R.string.exported, Toast.LENGTH_LONG).show(); - } catch (Exception e) { - Toast.makeText(this, getString(R.string.export_failed, String.valueOf(e.getMessage())), - Toast.LENGTH_LONG).show(); - } - } - - // --- Transcription ---------------------------------------------------- - - private void onTranscribeClicked() { - ModelRepository.WhisperModel model = modelSpinner.getSelectedItemPosition() == 1 - ? ModelRepository.WhisperModel.TINY - : ModelRepository.WhisperModel.BASE; - String language = languageFor(languageSpinner.getSelectedItemPosition()); - - if (!models.isReady(model)) { - promptDownload(model, language); - } else { - runTranscription(model, language); - } - } - - private void promptDownload(ModelRepository.WhisperModel model, String language) { - new AlertDialog.Builder(this) - .setTitle(R.string.model_download_title) - .setMessage(getString(R.string.model_download_message, - model.label, Formats.humanBytes(model.totalBytes()))) - .setPositiveButton(R.string.download, (dialog, which) -> runDownload(model, language)) - .setNegativeButton(R.string.cancel, null) - .show(); - } - - private void runDownload(ModelRepository.WhisperModel model, String language) { - transcribeButton.setEnabled(false); - progressBar.setVisibility(View.VISIBLE); - progressBar.setIndeterminate(false); - progressBar.setProgress(0); - statusText.setText(R.string.status_preparing); - - new Thread(() -> { - try { - models.download(model, (downloaded, total, file) -> runOnUiThread(() -> { - int permille = (int) (downloaded * 1000 / Math.max(1, total)); - progressBar.setProgress(permille); - statusText.setText(getString( - R.string.status_downloading, file, permille / 10)); - })); - runOnUiThread(() -> { - statusText.setText(R.string.status_model_ready); - runTranscription(model, language); - }); - } catch (Exception e) { - runOnUiThread(() -> { - transcribeButton.setEnabled(true); - progressBar.setVisibility(View.GONE); - statusText.setText(getString(R.string.err_download, String.valueOf(e.getMessage()))); - }); - } - }, "model-download").start(); - } - - private void runTranscription(ModelRepository.WhisperModel model, String language) { - cancelled = false; - transcribeButton.setEnabled(false); - transcriptContainer.removeAllViews(); - progressBar.setVisibility(View.VISIBLE); - progressBar.setIndeterminate(true); - statusText.setText(R.string.status_preparing); - - new Thread(() -> { - TranscriptionEngine.Result result; - try { - TranscriptionEngine engine = new TranscriptionEngine(PlayerActivity.this); - result = engine.transcribe(recordingUri, model, language, - new TranscriptionEngine.Callback() { - @Override - public void onProgress(int percent, String message) { - runOnUiThread(() -> { - progressBar.setIndeterminate(false); - progressBar.setProgress(percent * 10); - statusText.setText(message); - }); - } - - @Override - public boolean isCancelled() { - return cancelled; - } - }); - } catch (Exception e) { - runOnUiThread(() -> statusText.setText( - getString(R.string.err_transcribe, String.valueOf(e.getMessage())))); - return; - } finally { - runOnUiThread(() -> { - transcribeButton.setEnabled(true); - progressBar.setVisibility(View.GONE); - }); - } - - if (result == null) { - return; - } - try { - transcripts.save(recordingName, result); - } catch (Exception ignored) { - // The transcript is still shown below. - } - runOnUiThread(() -> showTranscript(result)); - }, "transcribe").start(); - } - - private void showTranscript(TranscriptionEngine.Result result) { - transcriptContainer.removeAllViews(); - if (result.segments.isEmpty()) { - statusText.setText(R.string.status_no_speech); - return; - } - for (TranscriptionEngine.Segment segment : result.segments) { - addTranscriptLine(segment); - } - statusText.setText(R.string.status_transcript_saved); - } - - private void addTranscriptLine(TranscriptionEngine.Segment segment) { - SpannableStringBuilder builder = new SpannableStringBuilder(); - builder.append("[").append(Formats.clock((int) (segment.startSeconds * 1000))).append("] "); - - if (segment.words.isEmpty()) { - builder.append(segment.text); - } else { - for (TranscriptionEngine.Word word : segment.words) { - int start = builder.length(); - builder.append(word.text); - int end = builder.length(); - if (end > start) { - final int seekMs = (int) (word.startSeconds * 1000); - builder.setSpan(new ClickableSpan() { - @Override - public void onClick(@NonNull View widget) { - seekTo(seekMs); - } - - @Override - public void updateDrawState(@NonNull TextPaint ds) { - ds.setColor(getResources().getColor(R.color.text_primary, getTheme())); - ds.setUnderlineText(false); - } - }, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - } - } - } - - TextView view = new TextView(this); - view.setText(builder); - view.setTextColor(getResources().getColor(R.color.text_primary, getTheme())); - view.setTextSize(15f); - view.setPadding(0, 12, 0, 12); - view.setMovementMethod(LinkMovementMethod.getInstance()); - view.setHighlightColor(Color.TRANSPARENT); - view.setOnClickListener(v -> seekTo((int) (segment.startSeconds * 1000))); - transcriptContainer.addView(view); - } - - private void showMarkers(List markers) { - markersContainer.removeAllViews(); - markersSection.setVisibility(markers.isEmpty() ? View.GONE : View.VISIBLE); - float density = getResources().getDisplayMetrics().density; - for (Marker marker : markers) { - TextView chip = new TextView(this); - StringBuilder text = new StringBuilder(); - text.append('M').append(marker.index) - .append(" ").append(Formats.clock((int) marker.timeMillis())); - if (!marker.label.isEmpty()) { - text.append(" ").append(marker.label); - } - chip.setText(text.toString()); - chip.setTextColor(getResources().getColor(R.color.text_primary, getTheme())); - chip.setTextSize(13f); - chip.setPadding((int) (12 * density), (int) (6 * density), - (int) (12 * density), (int) (6 * density)); - chip.setBackgroundResource(R.drawable.badge_marker); - chip.setOnClickListener(v -> seekTo((int) marker.timeMillis())); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT); - params.rightMargin = (int) (8 * density); - chip.setLayoutParams(params); - markersContainer.addView(chip); - } - } - - private void seekTo(int millis) { - if (player == null || !prepared) { - return; - } - int target = Math.max(0, Math.min(millis, player.getDuration())); - player.seekTo(target); - seekBar.setProgress(target); - if (!player.isPlaying()) { - player.start(); - applySpeed(); - } - updateTimeLabel(); - } - - private static String languageFor(int position) { - switch (position) { - case 1: - return TranscriptionEngine.LANG_EN; - case 2: - return TranscriptionEngine.LANG_IT; - default: - return TranscriptionEngine.LANG_AUTO; - } - } - - @Override - protected void onPause() { - super.onPause(); - if (player != null && prepared) { - try { - if (player.isPlaying()) { - player.pause(); - } - } catch (Exception ignored) { - // Ignore. - } - updatePlayButton(); - } - } - - @Override - protected void onDestroy() { - cancelled = true; - handler.removeCallbacksAndMessages(null); - if (player != null) { - try { - player.release(); - } catch (Exception ignored) { - // Ignore. - } - player = null; - } - super.onDestroy(); - } -} diff --git a/app/src/main/java/com/wuhei/reccoon/RecordingActions.java b/app/src/main/java/com/wuhei/reccoon/RecordingActions.java deleted file mode 100644 index 4131181..0000000 --- a/app/src/main/java/com/wuhei/reccoon/RecordingActions.java +++ /dev/null @@ -1,149 +0,0 @@ -package com.wuhei.reccoon; - -import android.content.ContentResolver; -import android.content.ContentValues; -import android.content.Context; -import android.media.MediaScannerConnection; -import android.net.Uri; -import android.os.Build; -import android.os.Environment; -import android.provider.MediaStore; - -import androidx.core.content.FileProvider; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.nio.charset.StandardCharsets; -import java.util.Locale; - -/** Delete / rename / share helpers for saved recordings. */ -public final class RecordingActions { - - private RecordingActions() { - } - - public static void delete(Context context, Uri uri, String name) { - try { - if ("file".equals(uri.getScheme())) { - //noinspection ResultOfMethodCallIgnored - new File(uri.getPath()).delete(); - } else { - context.getContentResolver().delete(uri, null, null); - } - } catch (Exception ignored) { - // Keep going: also remove the sidecars. - } - new TranscriptStore(context).delete(name); - new MarkerStore(context).delete(name); - } - - /** @return the new file name, or null if renaming failed. */ - public static String rename(Context context, Uri uri, String oldName, String newName) { - if (newName == null || newName.trim().isEmpty() || !newName.endsWith(".wav")) { - return null; - } - newName = newName.trim(); - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !"file".equals(uri.getScheme())) { - ContentValues values = new ContentValues(); - values.put(MediaStore.MediaColumns.DISPLAY_NAME, newName); - context.getContentResolver().update(uri, values, null, null); - } else { - File oldFile = new File(uri.getPath()); - File newFile = new File(oldFile.getParentFile(), newName); - if (!oldFile.renameTo(newFile)) { - return null; - } - MediaScannerConnection.scanFile(context, - new String[]{newFile.getAbsolutePath()}, - new String[]{"audio/wav"}, null); - } - } catch (Exception e) { - return null; - } - new TranscriptStore(context).rename(oldName, newName); - new MarkerStore(context).rename(oldName, newName); - return newName; - } - - /** A URI that can be handed to other apps (FileProvider for file:// paths). */ - public static Uri shareableUri(Context context, Uri uri) { - if ("file".equals(uri.getScheme())) { - File file = new File(uri.getPath()); - return FileProvider.getUriForFile( - context, context.getPackageName() + ".fileprovider", file); - } - return uri; - } - - public static void exportTranscript(Context context, String recordingName, - TranscriptionEngine.Result result) throws IOException { - if (result == null || result.segments.isEmpty()) { - throw new IOException("Nothing to export"); - } - String stem = recordingName.endsWith(".wav") - ? recordingName.substring(0, recordingName.length() - 4) - : recordingName; - writeToDownloads(context, stem + ".txt", "text/plain", result.asText()); - writeToDownloads(context, stem + ".srt", "application/x-subrip", toSrt(result)); - } - - public static String toSrt(TranscriptionEngine.Result result) { - StringBuilder sb = new StringBuilder(); - int index = 1; - for (TranscriptionEngine.Segment segment : result.segments) { - sb.append(index++).append('\n'); - sb.append(srtTime(segment.startSeconds)).append(" --> ") - .append(srtTime(segment.endSeconds)).append('\n'); - sb.append(segment.text.trim()).append("\n\n"); - } - return sb.toString(); - } - - private static String srtTime(float seconds) { - int total = Math.round(Math.max(0f, seconds) * 1000f); - int millis = total % 1000; - int secs = (total / 1000) % 60; - int mins = (total / 60000) % 60; - int hours = total / 3600000; - return String.format(Locale.US, "%02d:%02d:%02d,%03d", hours, mins, secs, millis); - } - - private static void writeToDownloads(Context context, String name, String mime, String content) - throws IOException { - byte[] bytes = content.getBytes(StandardCharsets.UTF_8); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - ContentResolver resolver = context.getContentResolver(); - ContentValues values = new ContentValues(); - values.put(MediaStore.MediaColumns.DISPLAY_NAME, name); - values.put(MediaStore.MediaColumns.MIME_TYPE, mime); - values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS); - values.put(MediaStore.MediaColumns.IS_PENDING, 1); - Uri uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values); - if (uri == null) { - throw new IOException("MediaStore rejected " + name); - } - try (OutputStream out = resolver.openOutputStream(uri)) { - if (out == null) { - throw new IOException("No output stream for " + name); - } - out.write(bytes); - } - ContentValues done = new ContentValues(); - done.put(MediaStore.MediaColumns.IS_PENDING, 0); - resolver.update(uri, done, null, null); - } else { - File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); - if (!dir.exists() && !dir.mkdirs()) { - throw new IOException("Could not create Downloads"); - } - File out = new File(dir, name); - try (FileOutputStream fos = new FileOutputStream(out)) { - fos.write(bytes); - } - MediaScannerConnection.scanFile(context, new String[]{out.getAbsolutePath()}, null, null); - } - } -} diff --git a/app/src/main/java/com/wuhei/reccoon/RecordingService.java b/app/src/main/java/com/wuhei/reccoon/RecordingService.java deleted file mode 100644 index 830c3bb..0000000 --- a/app/src/main/java/com/wuhei/reccoon/RecordingService.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.wuhei.reccoon; - -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.Service; -import android.content.Intent; -import android.content.pm.ServiceInfo; -import android.os.Build; -import android.os.IBinder; - -import androidx.core.app.NotificationCompat; - -/** - * Keeps the process alive (and the microphone accessible) while a recording is - * in progress, so recording continues when the app goes to the background. - */ -public class RecordingService extends Service { - - private static final String CHANNEL_ID = "reccoon_recording"; - private static final int NOTIFICATION_ID = 1; - - public static final String ACTION_START = "com.wuhei.reccoon.action.START"; - public static final String ACTION_STOP = "com.wuhei.reccoon.action.STOP"; - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - if (intent != null && ACTION_STOP.equals(intent.getAction())) { - stopForeground(true); - stopSelf(); - return START_NOT_STICKY; - } - createChannel(); - Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle(getString(R.string.app_name)) - .setContentText(getString(R.string.notification_recording)) - .setSmallIcon(R.drawable.ic_rec_dot) - .setOngoing(true) - .setPriority(NotificationCompat.PRIORITY_LOW) - .build(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground(NOTIFICATION_ID, notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE); - } else { - startForeground(NOTIFICATION_ID, notification); - } - return START_NOT_STICKY; - } - - private void createChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationManager manager = getSystemService(NotificationManager.class); - if (manager != null && manager.getNotificationChannel(CHANNEL_ID) == null) { - NotificationChannel channel = new NotificationChannel( - CHANNEL_ID, - getString(R.string.notification_channel), - NotificationManager.IMPORTANCE_LOW); - channel.setShowBadge(false); - manager.createNotificationChannel(channel); - } - } - } - - @Override - public IBinder onBind(Intent intent) { - return null; - } -} diff --git a/app/src/main/java/com/wuhei/reccoon/RecordingStore.java b/app/src/main/java/com/wuhei/reccoon/RecordingStore.java deleted file mode 100644 index 12abb06..0000000 --- a/app/src/main/java/com/wuhei/reccoon/RecordingStore.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.wuhei.reccoon; - -import android.content.Context; -import android.content.ContentUris; -import android.database.Cursor; -import android.net.Uri; -import android.os.Build; -import android.os.Environment; -import android.provider.MediaStore; - -import java.io.File; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -/** Lists the WAV files saved by RECCoon in the public Downloads folder. */ -public final class RecordingStore { - - public static final class Recording { - public final String name; - public final Uri uri; - public final long sizeBytes; - public final long modifiedSeconds; - public long durationMs; - - Recording(String name, Uri uri, long sizeBytes, long modifiedSeconds) { - this.name = name; - this.uri = uri; - this.sizeBytes = sizeBytes; - this.modifiedSeconds = modifiedSeconds; - } - } - - private static final String PREFIX = "reccoon-"; - private static final String SUFFIX = ".wav"; - - private final Context context; - - public RecordingStore(Context context) { - this.context = context.getApplicationContext(); - } - - public List list() { - List recordings = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q - ? listFromMediaStore() - : listFromDirectory(); - for (Recording recording : recordings) { - recording.durationMs = TranscriptionEngine.durationMillis(context, recording.uri); - } - Collections.sort(recordings, (a, b) -> Long.compare(b.modifiedSeconds, a.modifiedSeconds)); - return recordings; - } - - private List listFromMediaStore() { - List result = new ArrayList<>(); - String[] projection = { - MediaStore.MediaColumns._ID, - MediaStore.MediaColumns.DISPLAY_NAME, - MediaStore.MediaColumns.SIZE, - MediaStore.MediaColumns.DATE_MODIFIED, - }; - String selection = MediaStore.MediaColumns.DISPLAY_NAME + " LIKE ? AND " - + MediaStore.MediaColumns.DISPLAY_NAME + " LIKE ?"; - String[] args = {PREFIX + "%", "%" + SUFFIX}; - try (Cursor cursor = context.getContentResolver().query( - MediaStore.Downloads.EXTERNAL_CONTENT_URI, - projection, selection, args, null)) { - if (cursor == null) { - return result; - } - int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID); - int nameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME); - int sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE); - int dateColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED); - while (cursor.moveToNext()) { - long id = cursor.getLong(idColumn); - Uri uri = ContentUris.withAppendedId( - MediaStore.Downloads.EXTERNAL_CONTENT_URI, id); - result.add(new Recording( - cursor.getString(nameColumn), - uri, - cursor.getLong(sizeColumn), - cursor.getLong(dateColumn))); - } - } catch (Exception e) { - // Ignore and return what we have. - } - return result; - } - - @SuppressWarnings("deprecation") - private List listFromDirectory() { - List result = new ArrayList<>(); - File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); - File[] files = dir.listFiles((d, name) -> name.startsWith(PREFIX) && name.endsWith(SUFFIX)); - if (files == null) { - return result; - } - for (File file : files) { - result.add(new Recording( - file.getName(), - Uri.fromFile(file), - file.length(), - file.lastModified() / 1000L)); - } - return result; - } -} diff --git a/app/src/main/java/com/wuhei/reccoon/StreamingResampler.java b/app/src/main/java/com/wuhei/reccoon/StreamingResampler.java deleted file mode 100644 index beb372a..0000000 --- a/app/src/main/java/com/wuhei/reccoon/StreamingResampler.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.wuhei.reccoon; - -import java.util.Arrays; - -/** - * Linear-interpolation streaming resampler. - * - *

Keeps the fractional read position between calls so chunks can be fed - * continuously without glitches at the boundaries. - */ -final class StreamingResampler { - - private static final float[] EMPTY = new float[0]; - - private final double ratio; - private float[] buffer = new float[0]; - private double position; - - StreamingResampler(int inputRate, int outputRate) { - if (inputRate <= 0 || outputRate <= 0) { - throw new IllegalArgumentException("Sample rates must be positive"); - } - this.ratio = inputRate / (double) outputRate; - } - - float[] process(float[] input) { - if (input == null || input.length == 0) { - return EMPTY; - } - float[] combined = Arrays.copyOf(buffer, buffer.length + input.length); - System.arraycopy(input, 0, combined, buffer.length, input.length); - buffer = combined; - - int capacity = (int) Math.max(1, (buffer.length - position) / ratio + 2); - float[] output = new float[capacity]; - int count = 0; - while (position + 1 < buffer.length && count < output.length) { - int index = (int) position; - float fraction = (float) (position - index); - output[count++] = buffer[index] * (1f - fraction) + buffer[index + 1] * fraction; - position += ratio; - } - - // Drop the samples we have fully passed. `position` can overshoot the - // buffer end by up to one step, so clamp before trimming: otherwise - // Arrays.copyOfRange throws "from > to". - int drop = (int) position; - if (drop > buffer.length) { - drop = buffer.length; - } - if (drop > 0) { - buffer = Arrays.copyOfRange(buffer, drop, buffer.length); - position -= drop; - } - return count == output.length ? output : Arrays.copyOf(output, count); - } -} diff --git a/app/src/main/java/com/wuhei/reccoon/TranscriptStore.java b/app/src/main/java/com/wuhei/reccoon/TranscriptStore.java deleted file mode 100644 index 7ae336e..0000000 --- a/app/src/main/java/com/wuhei/reccoon/TranscriptStore.java +++ /dev/null @@ -1,129 +0,0 @@ -package com.wuhei.reccoon; - -import android.content.Context; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; - -/** Stores and loads transcript sidecar files in app-private storage. */ -public final class TranscriptStore { - - private final File dir; - - public TranscriptStore(Context context) { - dir = new File(context.getApplicationContext().getFilesDir(), "transcripts"); - } - - public boolean has(String recordingName) { - return fileFor(recordingName).isFile(); - } - - public void save(String recordingName, TranscriptionEngine.Result result) throws IOException { - JSONObject root = new JSONObject(); - JSONArray segments = new JSONArray(); - try { - root.put("language", result.language == null ? "" : result.language); - for (TranscriptionEngine.Segment segment : result.segments) { - JSONObject item = new JSONObject(); - item.put("start", segment.startSeconds); - item.put("end", segment.endSeconds); - item.put("text", segment.text); - JSONArray words = new JSONArray(); - for (TranscriptionEngine.Word word : segment.words) { - JSONObject w = new JSONObject(); - w.put("text", word.text); - w.put("start", word.startSeconds); - w.put("end", word.endSeconds); - words.put(w); - } - item.put("words", words); - segments.put(item); - } - root.put("segments", segments); - } catch (JSONException e) { - throw new IOException(e); - } - - if (!dir.exists() && !dir.mkdirs()) { - throw new IOException("Cannot create transcript directory"); - } - String json; - try { - json = root.toString(2); - } catch (JSONException e) { - throw new IOException(e); - } - try (FileOutputStream out = new FileOutputStream(fileFor(recordingName))) { - out.write(json.getBytes(StandardCharsets.UTF_8)); - } - } - - public TranscriptionEngine.Result load(String recordingName) { - File file = fileFor(recordingName); - if (!file.isFile()) { - return null; - } - try (InputStream in = new java.io.FileInputStream(file)) { - byte[] buffer = new byte[(int) file.length()]; - int read = 0; - while (read < buffer.length) { - int n = in.read(buffer, read, buffer.length - read); - if (n < 0) { - break; - } - read += n; - } - JSONObject root = new JSONObject(new String(buffer, 0, read, StandardCharsets.UTF_8)); - TranscriptionEngine.Result result = new TranscriptionEngine.Result(); - result.language = root.optString("language", ""); - JSONArray segments = root.optJSONArray("segments"); - if (segments != null) { - for (int i = 0; i < segments.length(); i++) { - JSONObject item = segments.getJSONObject(i); - TranscriptionEngine.Segment segment = new TranscriptionEngine.Segment( - (float) item.optDouble("start", 0), - (float) item.optDouble("end", 0), - item.optString("text", "")); - JSONArray words = item.optJSONArray("words"); - if (words != null) { - for (int j = 0; j < words.length(); j++) { - JSONObject w = words.getJSONObject(j); - segment.words.add(new TranscriptionEngine.Word( - w.optString("text", ""), - (float) w.optDouble("start", 0), - (float) w.optDouble("end", 0))); - } - } - result.segments.add(segment); - } - } - return result; - } catch (Exception e) { - return null; - } - } - - public void delete(String recordingName) { - //noinspection ResultOfMethodCallIgnored - fileFor(recordingName).delete(); - } - - public void rename(String oldName, String newName) { - File from = fileFor(oldName); - if (from.isFile()) { - //noinspection ResultOfMethodCallIgnored - from.renameTo(fileFor(newName)); - } - } - - private File fileFor(String recordingName) { - return new File(dir, recordingName + ".json"); - } -} diff --git a/app/src/main/java/com/wuhei/reccoon/TranscriptionEngine.java b/app/src/main/java/com/wuhei/reccoon/TranscriptionEngine.java deleted file mode 100644 index 3579ab5..0000000 --- a/app/src/main/java/com/wuhei/reccoon/TranscriptionEngine.java +++ /dev/null @@ -1,418 +0,0 @@ -package com.wuhei.reccoon; - -import android.content.Context; -import android.net.Uri; - -import com.k2fsa.sherpa.onnx.FeatureConfig; -import com.k2fsa.sherpa.onnx.OfflineModelConfig; -import com.k2fsa.sherpa.onnx.OfflineRecognizer; -import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig; -import com.k2fsa.sherpa.onnx.OfflineRecognizerResult; -import com.k2fsa.sherpa.onnx.OfflineStream; -import com.k2fsa.sherpa.onnx.OfflineWhisperModelConfig; -import com.k2fsa.sherpa.onnx.SileroVadModelConfig; -import com.k2fsa.sherpa.onnx.SpeechSegment; -import com.k2fsa.sherpa.onnx.Vad; -import com.k2fsa.sherpa.onnx.VadModelConfig; - -import java.io.BufferedInputStream; -import java.io.DataInputStream; -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.List; - -/** - * On-device transcription using sherpa-onnx with a multilingual Whisper model. - * - *

Long recordings are streamed, resampled to 16 kHz and segmented with - * Silero VAD, then each detected speech segment is decoded. This keeps memory - * bounded regardless of the recording length (offline Whisper itself only - * handles up to 30 seconds at a time). - */ -public final class TranscriptionEngine { - - public static final String LANG_AUTO = ""; - public static final String LANG_EN = "en"; - public static final String LANG_IT = "it"; - - private static final int TARGET_RATE = 16_000; - - public static final class Word { - public final String text; - public final float startSeconds; - public final float endSeconds; - - public Word(String text, float startSeconds, float endSeconds) { - this.text = text; - this.startSeconds = startSeconds; - this.endSeconds = endSeconds; - } - } - - public static final class Segment { - public final float startSeconds; - public final float endSeconds; - public final String text; - public final List words = new ArrayList<>(); - - public Segment(float startSeconds, float endSeconds, String text) { - this.startSeconds = startSeconds; - this.endSeconds = endSeconds; - this.text = text; - } - } - - public static final class Result { - public final List segments = new ArrayList<>(); - public String language = ""; - - /** Plain transcript with one segment per paragraph. */ - public String asText() { - StringBuilder sb = new StringBuilder(); - for (Segment segment : segments) { - if (sb.length() > 0) { - sb.append('\n'); - } - sb.append(segment.text); - } - return sb.toString(); - } - } - - public interface Callback { - /** @param percent 0..100 */ - void onProgress(int percent, String message); - - boolean isCancelled(); - } - - private final Context context; - private final ModelRepository models; - - public TranscriptionEngine(Context context) { - this.context = context.getApplicationContext(); - this.models = new ModelRepository(this.context); - } - - public Result transcribe(Uri wavUri, - ModelRepository.WhisperModel model, - String language, - Callback callback) throws IOException { - if (!models.isReady(model)) { - throw new IOException("Model is not downloaded"); - } - - OfflineRecognizer recognizer = buildRecognizer(model, language); - Vad vad = buildVad(); - Result result = new Result(); - result.language = language; - - try (InputStream raw = context.getContentResolver().openInputStream(wavUri)) { - if (raw == null) { - throw new IOException("Cannot open " + wavUri); - } - BufferedInputStream buffered = new BufferedInputStream(raw, 256 * 1024); - WavInfo info = readWavHeader(buffered); - if (info.bitsPerSample != 16 || info.audioFormat != 1) { - throw new IOException("Only 16-bit PCM WAV files are supported"); - } - - StreamingResampler resampler = new StreamingResampler(info.sampleRate, TARGET_RATE); - long totalPcm16k = estimatePcm16kSamples(info); - long processed = 0; - - int frameBytes = info.channels * 2; - byte[] readBuffer = new byte[frameBytes * 8192]; - long remaining = info.dataSize > 0 ? info.dataSize : Long.MAX_VALUE; - - while (remaining > 0) { - if (callback != null && callback.isCancelled()) { - return null; - } - int toRead = (int) Math.min(readBuffer.length, remaining); - int read = buffered.read(readBuffer, 0, toRead); - if (read <= 0) { - break; - } - remaining -= read; - - int frames = read / frameBytes; - float[] mono = new float[frames]; - for (int i = 0; i < frames; i++) { - int offset = i * frameBytes; - if (info.channels == 1) { - mono[i] = pcm16(readBuffer, offset) / 32768f; - } else { - int sum = 0; - for (int c = 0; c < info.channels; c++) { - sum += pcm16(readBuffer, offset + c * 2); - } - mono[i] = sum / (float) info.channels / 32768f; - } - } - - float[] resampled = resampler.process(mono); - processed += resampled.length; - if (resampled.length > 0) { - vad.acceptWaveform(resampled); - drainSegments(vad, recognizer, result); - } - if (callback != null && totalPcm16k > 0) { - int percent = (int) Math.min(99, processed * 100 / totalPcm16k); - callback.onProgress(percent, "Transcribing…"); - } - } - - vad.flush(); - drainSegments(vad, recognizer, result); - } finally { - vad.release(); - recognizer.release(); - } - - if (callback != null) { - callback.onProgress(100, "Done"); - } - return result; - } - - private void drainSegments(Vad vad, OfflineRecognizer recognizer, Result result) { - while (!vad.empty()) { - SpeechSegment speech = vad.front(); - vad.pop(); - float[] samples = speech.getSamples(); - if (samples == null || samples.length < TARGET_RATE / 10) { - continue; // ignore blips shorter than 100 ms - } - OfflineStream stream = recognizer.createStream(); - try { - stream.acceptWaveform(samples, TARGET_RATE); - recognizer.decode(stream); - OfflineRecognizerResult decoded = recognizer.getResult(stream); - String text = decoded.getText().trim(); - if (!text.isEmpty()) { - float start = speech.getStart() / (float) TARGET_RATE; - float end = start + samples.length / (float) TARGET_RATE; - Segment segment = new Segment(start, end, text); - buildWords(segment, decoded.getTokens(), decoded.getTimestamps(), - decoded.getDurations()); - result.segments.add(segment); - } - } finally { - stream.release(); - } - } - } - - /** - * Fills in word timings. Uses token timestamps when the model provides - * them, otherwise distributes the words across the segment by length. - */ - private static void buildWords(Segment segment, String[] tokens, - float[] timestamps, float[] durations) { - if (tokens == null || tokens.length == 0) { - return; - } - boolean haveTimestamps = timestamps != null && timestamps.length >= tokens.length; - if (haveTimestamps) { - for (int i = 0; i < tokens.length; i++) { - if (tokens[i] == null || tokens[i].trim().isEmpty()) { - continue; - } - float start = segment.startSeconds + timestamps[i]; - float end = durations != null && i < durations.length && durations[i] > 0f - ? start + durations[i] : start + 0.2f; - segment.words.add(new Word(tokens[i], start, end)); - } - return; - } - int total = 0; - for (String token : tokens) { - if (token != null && !token.trim().isEmpty()) { - total += token.trim().length(); - } - } - if (total <= 0) { - return; - } - float span = Math.max(0.001f, segment.endSeconds - segment.startSeconds); - int consumed = 0; - for (String token : tokens) { - if (token == null || token.trim().isEmpty()) { - continue; - } - int weight = token.trim().length(); - float start = segment.startSeconds + span * consumed / total; - consumed += weight; - float end = segment.startSeconds + span * consumed / total; - segment.words.add(new Word(token, start, end)); - } - } - - private OfflineRecognizer buildRecognizer(ModelRepository.WhisperModel model, String language) { - FeatureConfig feat = new FeatureConfig(); - feat.setSampleRate(TARGET_RATE); - feat.setFeatureDim(80); - feat.setDither(0f); - - OfflineWhisperModelConfig whisper = new OfflineWhisperModelConfig(); - whisper.setEncoder(models.encoder(model).getAbsolutePath()); - whisper.setDecoder(models.decoder(model).getAbsolutePath()); - whisper.setLanguage(language == null ? "" : language); - whisper.setTask("transcribe"); - whisper.setTailPaddings(1000); - whisper.setEnableTokenTimestamps(true); - whisper.setEnableSegmentTimestamps(false); - - OfflineModelConfig modelConfig = new OfflineModelConfig(); - modelConfig.setWhisper(whisper); - modelConfig.setTokens(models.tokens(model).getAbsolutePath()); - modelConfig.setNumThreads(Math.max(2, Runtime.getRuntime().availableProcessors() / 2)); - modelConfig.setDebug(false); - modelConfig.setProvider("cpu"); - modelConfig.setModelType("whisper"); - - OfflineRecognizerConfig config = new OfflineRecognizerConfig(); - config.setFeatConfig(feat); - config.setModelConfig(modelConfig); - config.setDecodingMethod("greedy_search"); - - // Passing a null AssetManager makes sherpa-onnx load the model from - // the absolute file paths configured above. - return new OfflineRecognizer(null, config); - } - - private Vad buildVad() { - SileroVadModelConfig silero = new SileroVadModelConfig(); - silero.setModel(models.vadModel().getAbsolutePath()); - silero.setThreshold(0.5f); - silero.setMinSilenceDuration(0.5f); - silero.setMinSpeechDuration(0.25f); - silero.setWindowSize(512); - silero.setMaxSpeechDuration(20.0f); - - VadModelConfig config = new VadModelConfig(); - config.setSileroVadModelConfig(silero); - config.setSampleRate(TARGET_RATE); - config.setNumThreads(1); - config.setProvider("cpu"); - config.setDebug(false); - - return new Vad(null, config); - } - - // --- WAV handling ----------------------------------------------------- - - public static final class WavInfo { - public int audioFormat; - public int channels; - public int sampleRate; - public int bitsPerSample; - public long dataSize; - - public long durationMillis() { - int byteRate = sampleRate * channels * (bitsPerSample / 8); - if (byteRate <= 0 || dataSize <= 0) { - return 0; - } - return dataSize * 1000L / byteRate; - } - } - - public static WavInfo readWavHeader(InputStream stream) throws IOException { - DataInputStream in = stream instanceof DataInputStream - ? (DataInputStream) stream - : new DataInputStream(stream); - - byte[] header = new byte[12]; - in.readFully(header); - if (header[0] != 'R' || header[1] != 'I' || header[2] != 'F' || header[3] != 'F' - || header[8] != 'W' || header[9] != 'A' || header[10] != 'V' || header[11] != 'E') { - throw new IOException("Not a RIFF/WAVE file"); - } - - WavInfo info = new WavInfo(); - while (true) { - byte[] chunkHeader = new byte[8]; - in.readFully(chunkHeader); - String id = new String(chunkHeader, 0, 4, StandardCharsets.US_ASCII); - long size = le32(chunkHeader, 4) & 0xffffffffL; - - if ("fmt ".equals(id)) { - if (size < 16 || size > 1024) { - throw new IOException("Unsupported fmt chunk"); - } - byte[] fmt = new byte[(int) size]; - in.readFully(fmt); - info.audioFormat = (int) le16(fmt, 0); - info.channels = (int) le16(fmt, 2); - info.sampleRate = (int) le32(fmt, 4); - info.bitsPerSample = (int) le16(fmt, 14); - if ((size & 1) == 1) { - in.readFully(new byte[1]); - } - } else if ("data".equals(id)) { - info.dataSize = size; - return info; - } else { - skipFully(in, size + (size & 1)); - } - } - } - - public static long durationMillis(Context context, Uri uri) { - try (InputStream in = context.getContentResolver().openInputStream(uri)) { - if (in == null) { - return 0; - } - WavInfo info = readWavHeader(new BufferedInputStream(in)); - return info.durationMillis(); - } catch (Exception e) { - return 0; - } - } - - private static long estimatePcm16kSamples(WavInfo info) { - if (info.dataSize <= 0 || info.sampleRate <= 0) { - return 0; - } - int frameBytes = info.channels * (info.bitsPerSample / 8); - if (frameBytes <= 0) { - return 0; - } - long frames = info.dataSize / frameBytes; - return frames * TARGET_RATE / info.sampleRate; - } - - private static int pcm16(byte[] data, int offset) { - return (short) ((data[offset] & 0xff) | (data[offset + 1] << 8)); - } - - private static long le32(byte[] data, int offset) { - return (data[offset] & 0xffL) - | ((data[offset + 1] & 0xffL) << 8) - | ((data[offset + 2] & 0xffL) << 16) - | ((data[offset + 3] & 0xffL) << 24); - } - - private static long le16(byte[] data, int offset) { - return (data[offset] & 0xffL) | ((data[offset + 1] & 0xffL) << 8); - } - - private static void skipFully(DataInputStream in, long count) throws IOException { - long remaining = count; - while (remaining > 0) { - long skipped = in.skip(remaining); - if (skipped <= 0) { - if (in.read() < 0) { - throw new IOException("Unexpected end of WAV file"); - } - skipped = 1; - } - remaining -= skipped; - } - } -} diff --git a/app/src/main/java/com/wuhei/reccoon/WavMarkers.java b/app/src/main/java/com/wuhei/reccoon/WavMarkers.java deleted file mode 100644 index 1f55e96..0000000 --- a/app/src/main/java/com/wuhei/reccoon/WavMarkers.java +++ /dev/null @@ -1,121 +0,0 @@ -package com.wuhei.reccoon; - -import android.content.Context; -import android.net.Uri; - -import java.io.BufferedInputStream; -import java.io.DataInputStream; -import java.io.EOFException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -/** Reads {@code cue } + {@code LIST/adtl} marker chunks out of a WAV file. */ -public final class WavMarkers { - - private WavMarkers() { - } - - public static List read(Context context, Uri uri) { - List result = new ArrayList<>(); - try (InputStream raw = context.getContentResolver().openInputStream(uri)) { - if (raw == null) { - return result; - } - DataInputStream in = new DataInputStream(new BufferedInputStream(raw, 64 * 1024)); - byte[] header = new byte[12]; - in.readFully(header); - if (header[0] != 'R' || header[1] != 'I' || header[2] != 'F' || header[3] != 'F' - || header[8] != 'W' || header[9] != 'A' || header[10] != 'V' - || header[11] != 'E') { - return result; - } - - Map offsets = new LinkedHashMap<>(); - Map labels = new LinkedHashMap<>(); - - while (true) { - byte[] chunkHeader = new byte[8]; - try { - in.readFully(chunkHeader); - } catch (EOFException eof) { - break; - } - String id = new String(chunkHeader, 0, 4, StandardCharsets.US_ASCII); - long size = le32(chunkHeader, 4) & 0xffffffffL; - - if ("cue ".equals(id)) { - byte[] body = new byte[(int) size]; - in.readFully(body); - int count = (int) le32(body, 0); - for (int i = 0; i < count; i++) { - int offset = 4 + i * 24; - int name = (int) le32(body, offset); - long sample = le32(body, offset + 20) & 0xffffffffL; - offsets.put(name, sample); - } - } else if ("LIST".equals(id)) { - byte[] body = new byte[(int) size]; - in.readFully(body); - if (body.length >= 4 - && "adtl".equals(new String(body, 0, 4, StandardCharsets.US_ASCII))) { - int p = 4; - while (p + 8 <= body.length) { - String subId = new String(body, p, 4, StandardCharsets.US_ASCII); - long subSize = le32(body, p + 4) & 0xffffffffL; - if ("labl".equals(subId) && p + 8 + subSize <= body.length) { - int name = (int) le32(body, p + 8); - int textLength = (int) subSize - 4; - if (textLength >= 0) { - String text = new String(body, p + 12, textLength, - StandardCharsets.UTF_8); - int zero = text.indexOf('\0'); - if (zero >= 0) { - text = text.substring(0, zero); - } - labels.put(name, text); - } - } - p += 8 + subSize + (subSize & 1); - } - } - } else { - skipFully(in, size + (size & 1)); - } - } - - int index = 1; - for (Map.Entry entry : offsets.entrySet()) { - String label = labels.get(entry.getKey()); - result.add(new Marker(index++, entry.getValue(), label == null ? "" : label)); - } - } catch (Exception e) { - // Markers are best effort. - } - return result; - } - - private static long le32(byte[] data, int offset) { - return (data[offset] & 0xffL) - | ((data[offset + 1] & 0xffL) << 8) - | ((data[offset + 2] & 0xffL) << 16) - | ((data[offset + 3] & 0xffL) << 24); - } - - private static void skipFully(DataInputStream in, long count) throws Exception { - long remaining = count; - while (remaining > 0) { - long skipped = in.skip(remaining); - if (skipped <= 0) { - if (in.read() < 0) { - throw new EOFException(); - } - skipped = 1; - } - remaining -= skipped; - } - } -} diff --git a/app/src/main/java/com/wuhei/reccoon/WavRecorder.java b/app/src/main/java/com/wuhei/reccoon/WavRecorder.java deleted file mode 100644 index 8279847..0000000 --- a/app/src/main/java/com/wuhei/reccoon/WavRecorder.java +++ /dev/null @@ -1,407 +0,0 @@ -package com.wuhei.reccoon; - -import android.content.Context; -import android.media.AudioFormat; -import android.media.AudioRecord; -import android.media.MediaRecorder; -import android.os.SystemClock; - -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.RandomAccessFile; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -/** - * Records uncompressed 16-bit PCM audio at 44.1 kHz and writes a standard - * RIFF/WAVE file (PCM, format code 1). - * - *

Stereo is used when the device supports it (so the left/right level - * meters are meaningful); it falls back to mono otherwise. Peak levels and a - * small waveform envelope for both channels are reported through - * {@link LevelListener}. - * - *

Markers added while recording are embedded in the file as standard WAV - * {@code cue } + {@code LIST/adtl} chunks (readable by Audacity, Reaper, ...). - */ -public class WavRecorder { - - public static final int SAMPLE_RATE = 44100; - public static final int BITS_PER_SAMPLE = 16; - - private static final int HEADER_SIZE = 44; - private static final long LEVEL_INTERVAL_MS = 40; - private static final int WAVEFORM_BUCKETS = 16; - - public interface LevelListener { - /** - * @param leftPeak 0..1 peak of the left channel in this window - * @param rightPeak 0..1 peak of the right channel (same as left when mono) - * @param clipped true if any sample reached full scale in this window - * @param waveform min/max pairs (length {@code 2 * WAVEFORM_BUCKETS}) of - * the left/mono channel for a scrolling waveform - */ - void onLevels(float leftPeak, float rightPeak, boolean clipped, float[] waveform); - } - - private final Context context; - private final List markers = new ArrayList<>(); - - private LevelListener levelListener; - private AudioRecord audioRecord; - private Thread writerThread; - private volatile boolean recording; - private File tempFile; - private volatile long pcmBytes; - private int channels = 2; - private boolean forceMono; - - public WavRecorder(Context context) { - this.context = context.getApplicationContext(); - } - - public void setLevelListener(LevelListener listener) { - this.levelListener = listener; - } - - /** When true, always record a single channel (half the file size). */ - public void setForceMono(boolean forceMono) { - this.forceMono = forceMono; - } - - /** Effective number of channels used by the last/current recording. */ - public int getChannels() { - return channels; - } - - /** - * Starts capturing audio. Returns the file that will hold the WAV data. - */ - public File start() throws IOException { - if (recording) { - throw new IllegalStateException("A recording is already in progress"); - } - markers.clear(); - - audioRecord = createAudioRecord(); - if (audioRecord == null) { - throw new IOException("No usable microphone / audio configuration"); - } - - tempFile = new File(context.getCacheDir(), "reccoon_recording.wav"); - writePlaceholderHeader(); - - pcmBytes = 0L; - recording = true; - audioRecord.startRecording(); - - final AudioRecord record = audioRecord; - final int channelCount = channels; - final int frameBytes = channelCount * 2; - final int minBuffer = AudioRecord.getMinBufferSize( - SAMPLE_RATE, channelConfigFor(channelCount), AudioFormat.ENCODING_PCM_16BIT); - final int chunkSize = Math.max(minBuffer, frameBytes * 4096); - - writerThread = new Thread(() -> { - byte[] buffer = new byte[chunkSize]; - long lastLevelTime = 0; - try (FileOutputStream out = new FileOutputStream(tempFile, true)) { - while (recording) { - int read = record.read(buffer, 0, buffer.length); - if (read > 0) { - out.write(buffer, 0, read); - pcmBytes += read; - - long now = SystemClock.uptimeMillis(); - if (levelListener != null && now - lastLevelTime >= LEVEL_INTERVAL_MS) { - lastLevelTime = now; - reportLevels(buffer, read, channelCount); - } - } - } - out.flush(); - } catch (Exception ignored) { - // Whatever was captured before the failure stays in the file. - } - }, "reccoon-writer"); - writerThread.start(); - - return tempFile; - } - - private void reportLevels(byte[] buffer, int length, int channelCount) { - int frameBytes = channelCount * 2; - int frames = length / frameBytes; - if (frames <= 0) { - return; - } - float leftPeak = 0f; - float rightPeak = 0f; - boolean clipped = false; - for (int i = 0; i < frames; i++) { - int offset = i * frameBytes; - short left = pcm16(buffer, offset); - float l = Math.abs((int) left) / 32768f; - if (l > leftPeak) { - leftPeak = l; - } - if (Math.abs((int) left) >= 32767) { - clipped = true; - } - if (channelCount > 1) { - short right = pcm16(buffer, offset + 2); - float r = Math.abs((int) right) / 32768f; - if (r > rightPeak) { - rightPeak = r; - } - if (Math.abs((int) right) >= 32767) { - clipped = true; - } - } - } - if (channelCount == 1) { - rightPeak = leftPeak; - } - - float[] waveform = buildWaveform(buffer, frames, frameBytes); - LevelListener listener = levelListener; - if (listener != null) { - listener.onLevels(leftPeak, rightPeak, clipped, waveform); - } - } - - private static float[] buildWaveform(byte[] buffer, int frames, int frameBytes) { - float[] waveform = new float[WAVEFORM_BUCKETS * 2]; - int framesPerBucket = Math.max(1, frames / WAVEFORM_BUCKETS); - for (int bucket = 0; bucket < WAVEFORM_BUCKETS; bucket++) { - int start = bucket * framesPerBucket; - int end = bucket == WAVEFORM_BUCKETS - 1 ? frames : start + framesPerBucket; - float min = 0f; - float max = 0f; - for (int i = start; i < end && i < frames; i++) { - float sample = pcm16(buffer, i * frameBytes) / 32768f; - if (sample < min) { - min = sample; - } - if (sample > max) { - max = sample; - } - } - waveform[bucket * 2] = min; - waveform[bucket * 2 + 1] = max; - } - return waveform; - } - - /** Current position in sample frames (usable while recording). */ - public long currentSampleOffset() { - int blockAlign = Math.max(1, channels * 2); - return pcmBytes / blockAlign; - } - - /** Adds a marker at the current position. */ - public void addMarker(String label) { - addMarkerAt(currentSampleOffset(), label); - } - - public void addMarkerAt(long sampleOffset, String label) { - markers.add(new Marker(markers.size() + 1, sampleOffset, label)); - } - - public List getMarkers() { - return Collections.unmodifiableList(new ArrayList<>(markers)); - } - - /** - * Stops capturing and finalizes the WAV header. Returns the finished file. - */ - public File stop() { - recording = false; - - AudioRecord record = audioRecord; - if (record != null) { - try { - if (record.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING) { - record.stop(); - } - } catch (Exception ignored) { - // Ignore and continue tearing down. - } - } - - Thread thread = writerThread; - if (thread != null) { - try { - thread.join(3000); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - - if (record != null) { - try { - record.release(); - } catch (Exception ignored) { - // Ignore. - } - audioRecord = null; - } - writerThread = null; - - writeHeader(); - writeMarkers(); - return tempFile; - } - - public boolean isRecording() { - return recording; - } - - private AudioRecord createAudioRecord() { - int[] candidates = forceMono - ? new int[]{AudioFormat.CHANNEL_IN_MONO} - : new int[]{AudioFormat.CHANNEL_IN_STEREO, AudioFormat.CHANNEL_IN_MONO}; - for (int channelConfig : candidates) { - int minBuffer = AudioRecord.getMinBufferSize( - SAMPLE_RATE, channelConfig, AudioFormat.ENCODING_PCM_16BIT); - if (minBuffer <= 0) { - continue; - } - int count = channelConfig == AudioFormat.CHANNEL_IN_STEREO ? 2 : 1; - int bufferSize = Math.max(minBuffer, SAMPLE_RATE * count); - AudioRecord candidate = new AudioRecord( - MediaRecorder.AudioSource.MIC, - SAMPLE_RATE, - channelConfig, - AudioFormat.ENCODING_PCM_16BIT, - bufferSize); - if (candidate.getState() == AudioRecord.STATE_INITIALIZED) { - channels = count; - return candidate; - } - candidate.release(); - } - return null; - } - - private static int channelConfigFor(int channelCount) { - return channelCount > 1 ? AudioFormat.CHANNEL_IN_STEREO : AudioFormat.CHANNEL_IN_MONO; - } - - private static short pcm16(byte[] data, int offset) { - return (short) ((data[offset] & 0xff) | (data[offset + 1] << 8)); - } - - private void writePlaceholderHeader() throws IOException { - try (RandomAccessFile raf = new RandomAccessFile(tempFile, "rw")) { - raf.setLength(0); - raf.write(new byte[HEADER_SIZE]); - } - } - - private void writeHeader() { - try (RandomAccessFile raf = new RandomAccessFile(tempFile, "rw")) { - raf.seek(0); - raf.write(buildHeader(pcmBytes)); - } catch (IOException ignored) { - // The file is still usable except for the size fields. - } - } - - /** Appends {@code cue } and {@code LIST/adtl} chunks for the markers. */ - private void writeMarkers() { - if (markers.isEmpty() || tempFile == null || !tempFile.isFile()) { - return; - } - try (RandomAccessFile raf = new RandomAccessFile(tempFile, "rw")) { - raf.seek(raf.length()); - raf.write(buildCueChunk(markers)); - raf.write(buildAdtlChunk(markers)); - - long newLength = raf.length(); - raf.seek(4); - raf.write(intLE((int) (newLength - 8))); - } catch (IOException ignored) { - // Markers are a bonus; the audio itself stays valid. - } - } - - private static byte[] buildCueChunk(List markers) { - int count = markers.size(); - ByteBuffer buffer = ByteBuffer.allocate(8 + 4 + 24 * count).order(ByteOrder.LITTLE_ENDIAN); - buffer.put(ascii("cue ")); - buffer.putInt(4 + 24 * count); - buffer.putInt(count); - for (Marker marker : markers) { - buffer.putInt(marker.index); // dwName - buffer.putInt(0); // dwPosition - buffer.put(ascii("data")); // fccChunk - buffer.putInt(0); // dwChunkStart - buffer.putInt(0); // dwBlockStart - buffer.putInt((int) marker.sampleOffset); // dwSampleOffset - } - return buffer.array(); - } - - private static byte[] buildAdtlChunk(List markers) { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - for (Marker marker : markers) { - byte[] text = marker.label.getBytes(StandardCharsets.UTF_8); - int size = 4 + text.length + 1; // cue id + text + NUL - int padded = size + (size & 1); - ByteBuffer buffer = ByteBuffer.allocate(8 + padded).order(ByteOrder.LITTLE_ENDIAN); - buffer.put(ascii("labl")); - buffer.putInt(size); - buffer.putInt(marker.index); - buffer.put(text); - buffer.put((byte) 0); - while (buffer.position() < buffer.capacity()) { - buffer.put((byte) 0); - } - out.write(buffer.array(), 0, buffer.array().length); - } - byte[] adtl = out.toByteArray(); - - ByteBuffer list = ByteBuffer.allocate(12 + adtl.length).order(ByteOrder.LITTLE_ENDIAN); - list.put(ascii("LIST")); - list.putInt(4 + adtl.length); - list.put(ascii("adtl")); - list.put(adtl); - return list.array(); - } - - private byte[] buildHeader(long dataSize) { - int byteRate = SAMPLE_RATE * channels * BITS_PER_SAMPLE / 8; - int blockAlign = channels * BITS_PER_SAMPLE / 8; - - ByteBuffer buffer = ByteBuffer.allocate(HEADER_SIZE).order(ByteOrder.LITTLE_ENDIAN); - buffer.put(ascii("RIFF")); - buffer.putInt((int) (36 + dataSize)); - buffer.put(ascii("WAVE")); - buffer.put(ascii("fmt ")); - buffer.putInt(16); // PCM chunk size - buffer.putShort((short) 1); // PCM = 1 - buffer.putShort((short) channels); - buffer.putInt(SAMPLE_RATE); - buffer.putInt(byteRate); - buffer.putShort((short) blockAlign); - buffer.putShort((short) BITS_PER_SAMPLE); - buffer.put(ascii("data")); - buffer.putInt((int) dataSize); - return buffer.array(); - } - - private static byte[] intLE(int value) { - return ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(value).array(); - } - - private static byte[] ascii(String value) { - return value.getBytes(StandardCharsets.US_ASCII); - } -} diff --git a/app/src/main/java/com/wuhei/reccoon/WaveformView.java b/app/src/main/java/com/wuhei/reccoon/WaveformView.java deleted file mode 100644 index de62639..0000000 --- a/app/src/main/java/com/wuhei/reccoon/WaveformView.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.wuhei.reccoon; - -import android.content.Context; -import android.graphics.Canvas; -import android.graphics.Paint; -import android.util.AttributeSet; -import android.view.View; - -import androidx.annotation.Nullable; - -/** - * A scrolling waveform of the left/mono channel. Columns whose peak reaches - * full scale are drawn in red so clipping is easy to spot. - */ -public class WaveformView extends View { - - private static final int CAPACITY = 600; - - private final float[] mins = new float[CAPACITY]; - private final float[] maxs = new float[CAPACITY]; - private final boolean[] clipped = new boolean[CAPACITY]; - - private final Paint wavePaint = new Paint(Paint.ANTI_ALIAS_FLAG); - private final Paint clipPaint = new Paint(Paint.ANTI_ALIAS_FLAG); - private final Paint centerPaint = new Paint(Paint.ANTI_ALIAS_FLAG); - private final Paint backgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG); - - private int count; - private int writeIndex; - - public WaveformView(Context context) { - this(context, null); - } - - public WaveformView(Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - float density = getResources().getDisplayMetrics().density; - wavePaint.setColor(0xFF3DDC84); - wavePaint.setStrokeWidth(Math.max(1f, density)); - clipPaint.setColor(0xFFE53935); - clipPaint.setStrokeWidth(Math.max(1f, density)); - centerPaint.setColor(0x33FFFFFF); - centerPaint.setStrokeWidth(Math.max(1f, density * 0.6f)); - backgroundPaint.setColor(0x14000000); - } - - /** Appends min/max pairs ({@code [min0,max0,min1,max1,...]}) to the ring. */ - public void append(float[] waveform) { - if (waveform == null || waveform.length < 2) { - return; - } - for (int i = 0; i + 1 < waveform.length; i += 2) { - mins[writeIndex] = waveform[i]; - maxs[writeIndex] = waveform[i + 1]; - clipped[writeIndex] = Math.abs(waveform[i]) >= 0.999f - || Math.abs(waveform[i + 1]) >= 0.999f; - writeIndex = (writeIndex + 1) % CAPACITY; - if (count < CAPACITY) { - count++; - } - } - invalidate(); - } - - public void reset() { - count = 0; - writeIndex = 0; - invalidate(); - } - - @Override - protected void onDraw(Canvas canvas) { - super.onDraw(canvas); - int width = getWidth(); - int height = getHeight(); - if (width <= 0 || height <= 0) { - return; - } - canvas.drawRect(0, 0, width, height, backgroundPaint); - - float centerY = height / 2f; - canvas.drawLine(0, centerY, width, centerY, centerPaint); - - float amplitude = centerY * 0.92f; - float step = (float) width / CAPACITY; - for (int i = 0; i < count; i++) { - int index = (writeIndex - count + i + CAPACITY) % CAPACITY; - float x = i * step + step / 2f; - float top = centerY - maxs[index] * amplitude; - float bottom = centerY - mins[index] * amplitude; - canvas.drawLine(x, top, x, bottom, clipped[index] ? clipPaint : wavePaint); - } - } -} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index e919117..2f3a1ac 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -65,27 +65,113 @@ android:scaleType="centerInside" android:src="@android:color/transparent" /> - + + + + + + + + + + + + + + + + + + - -