mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-09-28 15:11:45 +02:00
feat: add Ghostgram features
- 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
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
#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
|
||||
Reference in New Issue
Block a user