mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-04-24 08:36:07 +02:00
03f35f40b5
- Anti-Delete: save deleted messages locally - Ghost Mode: hide online status, read receipts - Voice Morpher: audio processing effects - Device Spoof: spoof device info - Custom GhostIcon app icon - User Notes: personal notes for contacts - Misc settings and controllers - GPLv2 License
290 lines
9.3 KiB
Objective-C
290 lines
9.3 KiB
Objective-C
#import "VoiceMorpherProcessor.h"
|
|
#import "OggOpusReader.h"
|
|
#import "TGDataItem.h"
|
|
#import "TGOggOpusWriter.h"
|
|
|
|
@implementation VoiceMorpherProcessor
|
|
|
|
+ (float)pitchShiftForPreset:(VoiceMorpherPreset)preset {
|
|
switch (preset) {
|
|
case VoiceMorpherPresetDisabled:
|
|
return 0;
|
|
case VoiceMorpherPresetAnonymous:
|
|
return -200;
|
|
case VoiceMorpherPresetFemale:
|
|
return 600; // More feminine - higher pitch
|
|
case VoiceMorpherPresetMale:
|
|
return -300;
|
|
case VoiceMorpherPresetChild:
|
|
return 600;
|
|
case VoiceMorpherPresetRobot:
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
+ (float)rateForPreset:(VoiceMorpherPreset)preset {
|
|
switch (preset) {
|
|
case VoiceMorpherPresetDisabled:
|
|
return 1.0;
|
|
case VoiceMorpherPresetAnonymous:
|
|
return 0.95;
|
|
case VoiceMorpherPresetFemale:
|
|
return 1.08; // Slightly faster for feminine effect
|
|
case VoiceMorpherPresetMale:
|
|
return 0.95;
|
|
case VoiceMorpherPresetChild:
|
|
return 1.1;
|
|
case VoiceMorpherPresetRobot:
|
|
return 1.0;
|
|
}
|
|
}
|
|
|
|
+ (void)processOggData:(NSData *)inputData
|
|
preset:(VoiceMorpherPreset)preset
|
|
completion:
|
|
(void (^)(NSData *_Nullable, NSError *_Nullable))completion {
|
|
|
|
if (preset == VoiceMorpherPresetDisabled) {
|
|
completion(inputData, nil);
|
|
return;
|
|
}
|
|
|
|
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0),
|
|
^{
|
|
NSError *error = nil;
|
|
NSData *result = [self processOggDataSync:inputData
|
|
preset:preset
|
|
error:&error];
|
|
|
|
// Call completion on background thread to avoid deadlock
|
|
// when caller uses semaphore on main thread
|
|
completion(result, error);
|
|
});
|
|
}
|
|
|
|
+ (NSData *_Nullable)processOggDataSync:(NSData *)inputData
|
|
preset:(VoiceMorpherPreset)preset
|
|
error:(NSError **)error {
|
|
// Save input OGG to temp file for decoding
|
|
NSString *tempInputPath = [NSTemporaryDirectory()
|
|
stringByAppendingPathComponent:
|
|
[NSString
|
|
stringWithFormat:@"vm_in_%lld.ogg", (long long)[[NSDate date]
|
|
timeIntervalSince1970] *
|
|
1000]];
|
|
|
|
[inputData writeToFile:tempInputPath atomically:YES];
|
|
|
|
// Decode OGG to PCM
|
|
OggOpusReader *reader = [[OggOpusReader alloc] initWithPath:tempInputPath];
|
|
if (!reader) {
|
|
if (error) {
|
|
*error = [NSError
|
|
errorWithDomain:@"VoiceMorpher"
|
|
code:1
|
|
userInfo:@{
|
|
NSLocalizedDescriptionKey : @"Failed to open OGG file"
|
|
}];
|
|
}
|
|
[[NSFileManager defaultManager] removeItemAtPath:tempInputPath error:nil];
|
|
return nil;
|
|
}
|
|
|
|
// Opus outputs 16-bit stereo at 48kHz
|
|
NSMutableData *pcmData = [[NSMutableData alloc] init];
|
|
int16_t buffer[5760 * 2]; // Max frame size * channels
|
|
int32_t samplesRead;
|
|
|
|
while ((samplesRead = [reader read:buffer
|
|
bufSize:sizeof(buffer) / sizeof(buffer[0])]) > 0) {
|
|
[pcmData appendBytes:buffer length:samplesRead * sizeof(int16_t)];
|
|
}
|
|
|
|
[[NSFileManager defaultManager] removeItemAtPath:tempInputPath error:nil];
|
|
|
|
if (pcmData.length == 0) {
|
|
if (error) {
|
|
*error =
|
|
[NSError errorWithDomain:@"VoiceMorpher"
|
|
code:2
|
|
userInfo:@{
|
|
NSLocalizedDescriptionKey : @"No PCM data decoded"
|
|
}];
|
|
}
|
|
return nil;
|
|
}
|
|
|
|
// Apply voice effects using AVAudioEngine
|
|
NSData *processedPcm = [self applyEffectsToPcmData:pcmData
|
|
preset:preset
|
|
error:error];
|
|
if (!processedPcm) {
|
|
return nil;
|
|
}
|
|
|
|
// Encode processed PCM back to OGG
|
|
TGDataItem *dataItem = [[TGDataItem alloc] init];
|
|
TGOggOpusWriter *writer = [[TGOggOpusWriter alloc] init];
|
|
|
|
if (![writer beginWithDataItem:dataItem]) {
|
|
if (error) {
|
|
*error = [NSError
|
|
errorWithDomain:@"VoiceMorpher"
|
|
code:4
|
|
userInfo:@{
|
|
NSLocalizedDescriptionKey : @"Failed to begin OGG encoding"
|
|
}];
|
|
}
|
|
return nil;
|
|
}
|
|
|
|
// Write PCM data in frames (960 samples = 20ms at 48kHz)
|
|
const int frameSize = 960 * sizeof(int16_t);
|
|
const uint8_t *bytes = processedPcm.bytes;
|
|
NSUInteger remaining = processedPcm.length;
|
|
NSUInteger offset = 0;
|
|
|
|
while (remaining >= frameSize) {
|
|
[writer writeFrame:(uint8_t *)(bytes + offset) frameByteCount:frameSize];
|
|
offset += frameSize;
|
|
remaining -= frameSize;
|
|
}
|
|
|
|
if (remaining > 0) {
|
|
uint8_t lastFrame[frameSize];
|
|
memset(lastFrame, 0, frameSize);
|
|
memcpy(lastFrame, bytes + offset, remaining);
|
|
[writer writeFrame:lastFrame frameByteCount:frameSize];
|
|
}
|
|
|
|
return [dataItem data];
|
|
}
|
|
|
|
+ (NSData *_Nullable)applyEffectsToPcmData:(NSData *)pcmData
|
|
preset:(VoiceMorpherPreset)preset
|
|
error:(NSError **)error {
|
|
NSUInteger sampleCount = pcmData.length / sizeof(int16_t);
|
|
const int16_t *int16Samples = (const int16_t *)pcmData.bytes;
|
|
|
|
float *floatSamples = (float *)malloc(sampleCount * sizeof(float));
|
|
if (!floatSamples) {
|
|
if (error) {
|
|
*error = [NSError
|
|
errorWithDomain:@"VoiceMorpher"
|
|
code:5
|
|
userInfo:@{
|
|
NSLocalizedDescriptionKey : @"Memory allocation failed"
|
|
}];
|
|
}
|
|
return nil;
|
|
}
|
|
|
|
// Convert int16 to float (-1.0 to 1.0 range)
|
|
for (NSUInteger i = 0; i < sampleCount; i++) {
|
|
floatSamples[i] = (float)int16Samples[i] / 32768.0f;
|
|
}
|
|
|
|
// Create audio format (mono, 48kHz, float)
|
|
AVAudioFormat *format =
|
|
[[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatFloat32
|
|
sampleRate:48000
|
|
channels:1
|
|
interleaved:NO];
|
|
|
|
AVAudioFrameCount frameCount = (AVAudioFrameCount)sampleCount;
|
|
AVAudioPCMBuffer *inputBuffer =
|
|
[[AVAudioPCMBuffer alloc] initWithPCMFormat:format
|
|
frameCapacity:frameCount];
|
|
inputBuffer.frameLength = frameCount;
|
|
|
|
memcpy(inputBuffer.floatChannelData[0], floatSamples,
|
|
sampleCount * sizeof(float));
|
|
free(floatSamples);
|
|
|
|
// Create engine and nodes
|
|
AVAudioEngine *engine = [[AVAudioEngine alloc] init];
|
|
AVAudioPlayerNode *playerNode = [[AVAudioPlayerNode alloc] init];
|
|
AVAudioUnitTimePitch *pitchNode = [[AVAudioUnitTimePitch alloc] init];
|
|
|
|
pitchNode.pitch = [self pitchShiftForPreset:preset];
|
|
pitchNode.rate = [self rateForPreset:preset];
|
|
|
|
[engine attachNode:playerNode];
|
|
[engine attachNode:pitchNode];
|
|
[engine connect:playerNode to:pitchNode format:format];
|
|
|
|
AVAudioNode *lastNode = pitchNode;
|
|
|
|
if (preset == VoiceMorpherPresetRobot) {
|
|
AVAudioUnitDistortion *distortion = [[AVAudioUnitDistortion alloc] init];
|
|
[distortion loadFactoryPreset:AVAudioUnitDistortionPresetSpeechRadioTower];
|
|
distortion.wetDryMix = 40;
|
|
[engine attachNode:distortion];
|
|
[engine connect:pitchNode to:distortion format:format];
|
|
lastNode = distortion;
|
|
} else if (preset == VoiceMorpherPresetAnonymous) {
|
|
AVAudioUnitDistortion *distortion = [[AVAudioUnitDistortion alloc] init];
|
|
[distortion
|
|
loadFactoryPreset:AVAudioUnitDistortionPresetSpeechCosmicInterference];
|
|
distortion.wetDryMix = 30;
|
|
[engine attachNode:distortion];
|
|
[engine connect:pitchNode to:distortion format:format];
|
|
lastNode = distortion;
|
|
}
|
|
|
|
[engine connect:lastNode to:engine.mainMixerNode format:format];
|
|
|
|
__block NSMutableData *outputData = [[NSMutableData alloc] init];
|
|
|
|
[engine.mainMixerNode
|
|
installTapOnBus:0
|
|
bufferSize:4096
|
|
format:format
|
|
block:^(AVAudioPCMBuffer *buffer, AVAudioTime *when) {
|
|
float *samples = buffer.floatChannelData[0];
|
|
AVAudioFrameCount count = buffer.frameLength;
|
|
|
|
int16_t *int16Buffer =
|
|
(int16_t *)malloc(count * sizeof(int16_t));
|
|
for (AVAudioFrameCount i = 0; i < count; i++) {
|
|
float sample = samples[i];
|
|
if (sample > 1.0f)
|
|
sample = 1.0f;
|
|
if (sample < -1.0f)
|
|
sample = -1.0f;
|
|
int16Buffer[i] = (int16_t)(sample * 32767.0f);
|
|
}
|
|
|
|
[outputData appendBytes:int16Buffer
|
|
length:count * sizeof(int16_t)];
|
|
free(int16Buffer);
|
|
}];
|
|
|
|
NSError *startError = nil;
|
|
[engine startAndReturnError:&startError];
|
|
if (startError) {
|
|
if (error) {
|
|
*error = startError;
|
|
}
|
|
return nil;
|
|
}
|
|
|
|
[playerNode scheduleBuffer:inputBuffer
|
|
atTime:nil
|
|
options:0
|
|
completionHandler:nil];
|
|
[playerNode play];
|
|
|
|
float rate = [self rateForPreset:preset];
|
|
NSTimeInterval duration = (double)sampleCount / 48000.0 / rate + 0.5;
|
|
[NSThread sleepForTimeInterval:duration];
|
|
|
|
[playerNode stop];
|
|
[engine.mainMixerNode removeTapOnBus:0];
|
|
[engine stop];
|
|
|
|
return outputData;
|
|
}
|
|
|
|
@end
|